@gakim-digital/dexter-bridge 0.11.23 → 0.12.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 CHANGED
@@ -32,16 +32,15 @@ command cannot claim the pairing code for a different runtime.
32
32
 
33
33
  Dexter Framer runs are harness-first. The API sends one scoped outcome
34
34
  assignment to the connected Codex, Claude Code, or OpenCode runtime. That
35
- harness inspects and edits the selected Framer project directly through the
36
- official `@framer/agent` connection while the plugin remains the chat,
37
- progress, cancellation, and result interface.
35
+ harness inspects and edits the selected Framer project directly through
36
+ Framer MCP while the plugin remains the chat, progress, cancellation, and
37
+ result interface.
38
38
 
39
39
  The Framer harness receives only these project-scoped tools:
40
40
 
41
- - `framer_instructions` and `framer_context`
42
- - `framer_read_project`, `framer_verify_interactions`, and `framer_apply_changes`
43
- - restricted `framer_read` and `framer_write`
44
- - `progress_update`
41
+ - `framer_read` and `framer_edit`
42
+ - `framer_instructions` and `framer_view_image`
43
+ - `framer_plan_task`, `framer_verify_interactions`, and `framer_read_website`
45
44
 
46
45
  Native shell, filesystem, web, and unrelated-project access are disabled for
47
46
  Framer outcome runs. Publishing is denied unless a future assignment explicitly
@@ -57,27 +56,17 @@ the live node. Results are `verified`, `contradicted` (the value differs), or
57
56
  reported as unverified; an unchecked target or a contradiction leaves the run
58
57
  incomplete.
59
58
 
60
- Project connection is handled inside Dexter and completes before the model
59
+ Account connection is handled inside Dexter and completes before the model
61
60
  starts:
62
61
 
63
- - The plugin reads the exact active project or branch URL directly from Framer.
64
- - The user adds a Framer project API key once in Dexter. The API verifies it
65
- through Framer's documented `connect(projectId, apiKey)` plus
66
- `getProjectInfo()` flow, then encrypts and stores it only after that succeeds.
67
- - The active hosted runtime redeems that verified credential only for the
68
- matching run and device, then installs it with Framer’s official
69
- `project auth` command against the authoritative 20-character project ID.
70
- - The runtime opens an official Framer Agent session, reads the active branch
71
- back from Framer, and confirms the authoritative project ID again.
72
- - Rejected credentials are revoked. Replacing a key creates a new credential
73
- version, so a cached runtime session cannot reuse the replacement.
74
- - Hosted worker images bundle the Framer skills and seed them into each
75
- runtime's private writable home before processing runs.
76
-
77
- The user never runs `@framer/agent setup`, `project auth`, or `session new`.
78
- The raw project credential is scoped to the matching active run and paired
79
- device, is excluded from run payloads and logs, and is never sent to the Framer
80
- plugin after the one-time authenticated submission.
62
+ - The user connects a Framer account once. Dexter stores the refresh token
63
+ encrypted and gives the bridge only a short-lived access token.
64
+ - The bridge opens the project through Framer MCP. Edits on a published project
65
+ land on a Framer branch, and Dexter says so.
66
+ - Disconnecting in Dexter, or revoking Dexter in Framer, stops later runs until
67
+ the account is connected again.
68
+
69
+ The user never creates a project API key. The refresh token stays on the API.
81
70
 
82
71
  The bridge reports provider token usage on terminal events, including
83
72
  interrupted and cancelled runs. Claude-reported
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gakim-digital/dexter-bridge",
3
- "version": "0.11.23",
3
+ "version": "0.12.0",
4
4
  "description": "Local bridge for InstaWebAI and Dexter — runs Codex, Claude Code, or OpenCode on your machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,7 +39,6 @@
39
39
  "@ai-sdk/openai-compatible": "^3.0.41",
40
40
  "@ai-sdk/xai": "^4.0.50",
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.2.73",
42
- "@framer/agent": "0.0.46",
43
42
  "@modelcontextprotocol/sdk": "^1.30.0",
44
43
  "@opencode-ai/sdk": "^1.18.25",
45
44
  "@openrouter/ai-sdk-provider": "^3.0.0",
package/src/agent.js CHANGED
@@ -10,7 +10,6 @@ import {
10
10
  readFramerWebsite,
11
11
  isCompanionPairingInvalidError,
12
12
  postRunEvent,
13
- reportFramerProjectAuthorizationVerification,
14
13
  } from './api.js';
15
14
  import {
16
15
  createCompanionUsageAccumulator,
@@ -65,7 +64,7 @@ import {
65
64
  codexDynamicToolSpecs,
66
65
  createHarnessToolRuntime,
67
66
  } from './harnessTools.js';
68
- import { createFramerAgentToolRuntime } from './framerAgentTools.js';
67
+ import { createFramerAgentToolRuntime } from './framerTools.js';
69
68
 
70
69
  const HARNESS_MCP_SERVER_PATH = fileURLToPath(
71
70
  new URL('./harnessMcpServer.js', import.meta.url),
@@ -366,7 +365,7 @@ function claudeOutcomeSystemPrompt(product) {
366
365
  'Inspect the project, perform the requested work, verify the result, and return the structured outcome.',
367
366
  'Do not access local credentials, unrelated files, other projects, account settings, or billing.',
368
367
  'Do not publish unless the assignment explicitly authorizes publishing.',
369
- 'If framer_write is rejected by the safety boundary, do not retry JavaScript variants. Switch to framer_apply_changes, inspect the relevant state, or report the concrete blocker.',
368
+ 'Edit the project with framer_edit programs that call api.tree. If a program is rejected, read the error and repair that program instead of inventing another interface.',
370
369
  `Return only the structured outcome result requested by ${name}.`,
371
370
  ].join(' ');
372
371
  }
@@ -2383,7 +2382,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2383
2382
  );
2384
2383
  const framerAgentOutcome =
2385
2384
  run?.outcome?.kind === 'framer-project'
2386
- || run?.outcome?.context?.executionMode === 'framer-agent';
2385
+ || run?.outcome?.context?.executionMode === 'framer-mcp';
2387
2386
  // Every Dexter message starts a fresh provider session. The assignment
2388
2387
  // already carries the recent conversation, and resuming would make every
2389
2388
  // turn of this run re-read the whole previous run as well.
@@ -2678,8 +2677,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2678
2677
  env: options.env,
2679
2678
  trace: options.trace,
2680
2679
  onActivity: progress,
2681
- runCli: options.framerAgentRunCli,
2682
- fetchImpl: options.framerScreenshotFetch,
2680
+ mcpClient: options.framerMcpClient,
2683
2681
  authorizeProject:
2684
2682
  options.apiBaseUrl && options.deviceToken
2685
2683
  ? ({ initiate, forceRefresh, signal }) =>
@@ -2705,29 +2703,6 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2705
2703
  allowInsecureHttp: options.allowInsecureHttp,
2706
2704
  })
2707
2705
  : undefined,
2708
- verifyProjectAuthorization:
2709
- options.apiBaseUrl && options.deviceToken
2710
- ? ({
2711
- status,
2712
- framerProjectId,
2713
- framerProjectUrl,
2714
- credentialVersion,
2715
- errorCode,
2716
- signal,
2717
- }) =>
2718
- reportFramerProjectAuthorizationVerification(options.apiBaseUrl, {
2719
- deviceToken: options.deviceToken,
2720
- runId: run.runId,
2721
- status,
2722
- framerProjectId,
2723
- framerProjectUrl,
2724
- credentialVersion,
2725
- errorCode,
2726
- fetchImpl: options.fetchImpl,
2727
- signal,
2728
- allowInsecureHttp: options.allowInsecureHttp,
2729
- })
2730
- : undefined,
2731
2706
  })
2732
2707
  : createHarnessToolRuntime({
2733
2708
  assignment: executionOutcome,
@@ -2738,7 +2713,9 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2738
2713
  });
2739
2714
  let prompt = '';
2740
2715
  const successStatus = outcomeSuccessStatus(executionOutcome, product);
2741
- const schema = outcomeOutputSchema(successStatus);
2716
+ const schema = outcomeOutputSchema(successStatus, {
2717
+ reportsProjectChange: framerAgentOutcome,
2718
+ });
2742
2719
  assertOutcomeResponseContract(
2743
2720
  STRUCTURED_RESPONSE_CONTRACTS.OUTCOME,
2744
2721
  schema,
@@ -2996,10 +2973,16 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2996
2973
  ) {
2997
2974
  synchronization = await harnessTools.synchronize();
2998
2975
  }
2976
+ if (typeof harnessTools.assessCompletion === 'function') {
2977
+ await harnessTools.assessCompletion(rawCompletion);
2978
+ }
2979
+ const unappliedChangePrompt =
2980
+ rawCompletion.status === successStatus
2981
+ ? harnessTools.unappliedChangePrompt?.(rawCompletion) || null
2982
+ : null;
2999
2983
  const repairPrompt =
3000
2984
  rawCompletion.status === successStatus
3001
- && typeof harnessTools.verificationRepairPrompt === 'function'
3002
- ? harnessTools.verificationRepairPrompt()
2985
+ ? harnessTools.verificationRepairPrompt?.() || unappliedChangePrompt
3003
2986
  : harnessTools.unblockedFailurePrompt?.(rawCompletion) || null;
3004
2987
  if (
3005
2988
  repairPrompt
@@ -3009,9 +2992,11 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
3009
2992
  kind: 'status_update',
3010
2993
  source: 'harness',
3011
2994
  message:
3012
- rawCompletion.status === successStatus
3013
- ? 'I’m completing the final Framer verification before I finish.'
3014
- : 'Nothing is blocking this change, so I’m continuing the work.',
2995
+ repairPrompt === unappliedChangePrompt
2996
+ ? 'Nothing has changed in Framer yet, so I’m making the change before I finish.'
2997
+ : rawCompletion.status === successStatus
2998
+ ? 'I’m completing the final Framer verification before I finish.'
2999
+ : 'Nothing is blocking this change, so I’m continuing the work.',
3015
3000
  });
3016
3001
  const repairResult = await Promise.race([
3017
3002
  executeProviderOutcome(
@@ -3052,6 +3037,9 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
3052
3037
  updatedAt: Date.now(),
3053
3038
  });
3054
3039
  }
3040
+ if (typeof harnessTools.assessCompletion === 'function') {
3041
+ await harnessTools.assessCompletion(rawCompletion);
3042
+ }
3055
3043
  const completion =
3056
3044
  typeof harnessTools.finalizeCompletion === 'function'
3057
3045
  ? harnessTools.finalizeCompletion(rawCompletion)
@@ -3155,6 +3143,7 @@ export async function executeRun(
3155
3143
  inspectAgentAuthentication,
3156
3144
  runtimeProfile: suppliedRuntimeProfile,
3157
3145
  framerAgentRunCli,
3146
+ framerMcpClient,
3158
3147
  framerScreenshotFetch,
3159
3148
  allowInsecureHttp = false,
3160
3149
  } = {},
@@ -3275,6 +3264,7 @@ export async function executeRun(
3275
3264
  product,
3276
3265
  runtimeProfile,
3277
3266
  framerAgentRunCli,
3267
+ framerMcpClient,
3278
3268
  framerScreenshotFetch,
3279
3269
  apiBaseUrl,
3280
3270
  deviceToken,
@@ -494,7 +494,7 @@ export function createClaudeProgressParser(onProgress, options = {}) {
494
494
  const block = event?.type === 'content_block_start' ? event.content_block : null;
495
495
  if (block?.type !== 'tool_use' || !block.id || drafting.has(block.id)) return;
496
496
  const shortName = String(block.name || '').replace(/^mcp__instawebai__/, '');
497
- if (!['framer_apply_changes', 'framer_add_svg', 'framer_update_code_file', 'framer_write'].includes(shortName)) return;
497
+ if (shortName !== 'framer_edit') return;
498
498
  drafting.set(block.id, true);
499
499
  emit({
500
500
  kind: 'item_started',
package/src/api.js CHANGED
@@ -61,7 +61,6 @@ export async function requestJson(apiBaseUrl, path, {
61
61
  timedOut = true;
62
62
  controller.abort();
63
63
  }, Math.max(1, Number(timeoutMs) || 30_000));
64
- timeout.unref?.();
65
64
  try {
66
65
  const response = await fetchImpl(`${normalizeApiBaseUrl(apiBaseUrl, {
67
66
  allowInsecureHttp,
@@ -266,40 +265,6 @@ export function readFramerWebsite(apiBaseUrl, {
266
265
  );
267
266
  }
268
267
 
269
- export function reportFramerProjectAuthorizationVerification(apiBaseUrl, {
270
- deviceToken,
271
- runId,
272
- status,
273
- framerProjectId,
274
- framerProjectUrl,
275
- credentialVersion,
276
- errorCode,
277
- fetchImpl,
278
- signal,
279
- allowInsecureHttp,
280
- }) {
281
- return requestJson(
282
- apiBaseUrl,
283
- `/ai/framer/companion/runs/${encodeURIComponent(runId)}/framer-authorization/verification`,
284
- {
285
- method: 'POST',
286
- deviceToken,
287
- fetchImpl,
288
- signal,
289
- allowInsecureHttp,
290
- body: {
291
- status,
292
- framerProjectId,
293
- ...(framerProjectUrl ? { framerProjectUrl } : {}),
294
- credentialVersion,
295
- ...(errorCode ? { errorCode } : {}),
296
- platform: process.platform,
297
- companionVersion: BRIDGE_VERSION,
298
- },
299
- },
300
- );
301
- }
302
-
303
268
  export function postRunEvent(apiBaseUrl, {
304
269
  deviceToken,
305
270
  runId,
package/src/config.js CHANGED
@@ -20,7 +20,8 @@ export const BRIDGE_BUILD_FINGERPRINT = crypto
20
20
  './nativeSkills.js',
21
21
  './harnessTools.js',
22
22
  './harnessMcpServer.js',
23
- './framerAgentTools.js',
23
+ './framerTools.js',
24
+ './framerMcpClient.js',
24
25
  './framerBehaviorContract.js',
25
26
  './outcomeWorkspace.js',
26
27
  './runtimeProfiles.js',
@@ -41,7 +42,7 @@ export const BRIDGE_CAPABILITIES = [
41
42
  'model-turn-v1',
42
43
  'outcome-run-v1',
43
44
  'harness-tools-v1',
44
- 'framer-agent-v1',
45
+ 'framer-mcp-v1',
45
46
  'framer-project-bootstrap-v1',
46
47
  'framer-project-verification-v1',
47
48
  'framer-interaction-verification-v4',
@@ -1,5 +1,3 @@
1
- import crypto from 'node:crypto';
2
-
3
1
  // Dexter does not model Framer's interaction features. The model decides how
4
2
  // to build a behavior from Framer's own documentation; this module only checks
5
3
  // that the properties the model says carry the behavior exist on the live
@@ -111,8 +109,7 @@ function hasContent(value) {
111
109
 
112
110
  // Finds the property on the node itself. Child nodes are skipped so a
113
111
  // descendant cannot satisfy a claim about its parent. A dotted name such as
114
- // `link.href` or `hoverEffect.opacity`, the way the change language writes
115
- // it, reads the field inside the property's value.
112
+ // `link.href` or `hoverEffect.opacity` reads the field inside the property's value.
116
113
  function propertyValues(node, property) {
117
114
  const values = keyValues(node, property);
118
115
  const [head, ...path] = property.split('.');
@@ -230,89 +227,3 @@ export function summarizeBehaviorVerification(plan, checkResults) {
230
227
  : 'verified';
231
228
  return { status, behaviors };
232
229
  }
233
-
234
- export function summarizeFramerChanges(changes) {
235
- const commands = String(changes || '')
236
- .split(';')
237
- .map((command) => command.trim())
238
- .filter(Boolean)
239
- .slice(0, 200)
240
- .map((command) => {
241
- const created = command.match(/^\+([A-Za-z0-9_]+)\s+([^\s]+)/);
242
- if (created) {
243
- return {
244
- operation: 'create',
245
- nodeType: created[1],
246
- nodeId: created[2],
247
- parentId: command.match(/\bparent="([^"]+)"/)?.[1],
248
- };
249
- }
250
- const set = command.match(/^SET\s+([^\s]+)/i);
251
- if (set) {
252
- return {
253
- operation: 'set',
254
- nodeId: set[1],
255
- eventActions: Array.from(
256
- command.matchAll(
257
- /\b(on[A-Za-z]+)\.\d+\.action="([A-Z_]+)"/g,
258
- ),
259
- ).map((match) => ({ event: match[1], action: match[2] })),
260
- };
261
- }
262
- const variant = command.match(/^CREATE_VARIANT\s+([^\s]+)\s+from="([^"]+)"/i);
263
- if (variant) {
264
- return {
265
- operation: 'create_variant',
266
- nodeId: variant[1],
267
- sourceNodeId: variant[2],
268
- };
269
- }
270
- const removed = command.match(/^DEL\s+([^\s;]+)/i);
271
- if (removed) return { operation: 'delete', nodeId: removed[1] };
272
- const duplicated = command.match(/^DUPE\s+([^\s]+).*?\bnewId="([^"]+)"/i);
273
- if (duplicated) {
274
- return {
275
- operation: 'duplicate',
276
- nodeId: duplicated[2],
277
- sourceNodeId: duplicated[1],
278
- };
279
- }
280
- const moved = command.match(/^MOVE\s+([^\s]+)/i);
281
- if (moved) return { operation: 'move', nodeId: moved[1] };
282
- const operation = text(command.split(/\s+/)[0], 40).toLowerCase();
283
- return { operation: operation || 'unknown' };
284
- });
285
- return {
286
- changeHash: crypto
287
- .createHash('sha256')
288
- .update(String(changes || ''))
289
- .digest('hex'),
290
- commandCount: commands.length,
291
- commands,
292
- };
293
- }
294
-
295
- export function canonicalIdMappings(value) {
296
- const mappings = {};
297
- const visit = (current) => {
298
- if (Array.isArray(current)) {
299
- current.forEach(visit);
300
- return;
301
- }
302
- if (!isRecord(current)) return;
303
- if (isRecord(current.renamedIds)) {
304
- for (const [temporaryId, canonicalId] of Object.entries(
305
- current.renamedIds,
306
- )) {
307
- const safeTemporary = text(temporaryId, 200);
308
- const safeCanonical = text(canonicalId, 200);
309
- if (safeTemporary && safeCanonical) {
310
- mappings[safeTemporary] = safeCanonical;
311
- }
312
- }
313
- }
314
- Object.values(current).forEach(visit);
315
- };
316
- visit(value);
317
- return mappings;
318
- }
@@ -0,0 +1,214 @@
1
+ const MCP_ENDPOINT = 'https://mcp.framer.com/mcp';
2
+ // Framer recommends one long program per change, so a call may run for
3
+ // minutes. The SDK default of 60 seconds would abandon a program Framer is
4
+ // still applying and lose its change receipt.
5
+ const CALL_TIMEOUT_MS = 10 * 60_000;
6
+ const SESSION_EXPIRED_PATTERN = /unknown or expired session/i;
7
+ // open_project answers in text only: "Opened <id>. Pass session <handle> to
8
+ // every tool that works on it. …" followed by the project README.
9
+ const SESSION_TEXT_PATTERN = /\bPass session\s+([A-Za-z0-9_-]+)/i;
10
+
11
+ function textOf(result) {
12
+ const content = Array.isArray(result?.content) ? result.content : [];
13
+ return content
14
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
15
+ .map((part) => part.text)
16
+ .join('\n')
17
+ .trim();
18
+ }
19
+
20
+ function imagesOf(result) {
21
+ const content = Array.isArray(result?.content) ? result.content : [];
22
+ return content.flatMap((part) => {
23
+ if (part?.type !== 'image' || typeof part.data !== 'string') return [];
24
+ return [{
25
+ mimeType: typeof part.mimeType === 'string' ? part.mimeType : 'image/png',
26
+ data: part.data,
27
+ }];
28
+ });
29
+ }
30
+
31
+ function toolError(message, code, extra = {}) {
32
+ return Object.assign(new Error(message), { code, ...extra });
33
+ }
34
+
35
+ function unwrap(result) {
36
+ if (!result || result.isError) {
37
+ const message = textOf(result) || 'Framer MCP call failed.';
38
+ throw toolError(
39
+ message,
40
+ SESSION_EXPIRED_PATTERN.test(message) ? 'FRAMER_MCP_SESSION_EXPIRED' : 'FRAMER_MCP_TOOL_FAILED',
41
+ );
42
+ }
43
+ let structured = result.structuredContent && typeof result.structuredContent === 'object'
44
+ ? result.structuredContent
45
+ : null;
46
+ const text = textOf(result);
47
+ if (!structured && text) {
48
+ try {
49
+ const parsed = JSON.parse(text);
50
+ if (parsed && typeof parsed === 'object') structured = parsed;
51
+ } catch {
52
+ structured = null;
53
+ }
54
+ }
55
+ return {
56
+ structured: structured || {},
57
+ text,
58
+ images: imagesOf(result),
59
+ raw: result,
60
+ };
61
+ }
62
+
63
+ export function sessionFromOpenResult(result) {
64
+ const structured = result?.structured || {};
65
+ const value = structured.session || structured.sessionId || structured.handle;
66
+ if (typeof value === 'string' && value.trim()) return value.trim();
67
+ return String(result?.text || '').match(SESSION_TEXT_PATTERN)?.[1] || null;
68
+ }
69
+
70
+ // The README without the per-session preamble, so the guidance stays the same
71
+ // from run to run and can be cached.
72
+ export function readmeFromOpenResult(result) {
73
+ const structured = result?.structured || {};
74
+ const readme = structured.readme || structured.README;
75
+ const text = typeof readme === 'string' && readme.trim() ? readme : String(result?.text || '');
76
+ const heading = text.search(/^#\s/m);
77
+ if (heading > 0) return text.slice(heading).trim();
78
+ return text
79
+ .split(/\n\s*\n/)
80
+ .filter((paragraph) => !SESSION_TEXT_PATTERN.test(paragraph))
81
+ .join('\n\n')
82
+ .trim();
83
+ }
84
+
85
+ function isUnauthorized(error) {
86
+ return error?.code === 401 || /\bHTTP 401\b|status 401/i.test(String(error?.message || ''));
87
+ }
88
+
89
+ export function createFramerMcpClient({
90
+ getAccessToken,
91
+ callTool,
92
+ fetchImpl = globalThis.fetch,
93
+ endpoint = MCP_ENDPOINT,
94
+ timeoutMs = CALL_TIMEOUT_MS,
95
+ } = {}) {
96
+ let sdkClient = null;
97
+ let transport = null;
98
+ let session = null;
99
+ let readme = '';
100
+ let projectId = null;
101
+
102
+ // Every request carries the current access token, so an expiring token is
103
+ // replaced without reconnecting or reopening the project. A 401 means the
104
+ // request was refused before it ran, so retrying it once is safe.
105
+ async function authorizedFetch(url, init = {}) {
106
+ const send = async (force) => {
107
+ const headers = new Headers(init.headers || {});
108
+ headers.set('Authorization', `Bearer ${await getAccessToken({ force })}`);
109
+ return fetchImpl(url, { ...init, headers });
110
+ };
111
+ const response = await send(false);
112
+ if (response.status !== 401) return response;
113
+ return send(true);
114
+ }
115
+
116
+ async function connect() {
117
+ if (sdkClient) return;
118
+ const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
119
+ const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js');
120
+ transport = new StreamableHTTPClientTransport(new URL(endpoint), { fetch: authorizedFetch });
121
+ const client = new Client({ name: 'dexter-bridge', version: '1' });
122
+ await client.connect(transport);
123
+ sdkClient = client;
124
+ }
125
+
126
+ async function rawCall(name, args) {
127
+ if (typeof callTool === 'function') return callTool(name, args);
128
+ await connect();
129
+ try {
130
+ return await sdkClient.callTool(
131
+ { name, arguments: args },
132
+ undefined,
133
+ { timeout: timeoutMs, resetTimeoutOnProgress: true },
134
+ );
135
+ } catch (error) {
136
+ if (isUnauthorized(error)) {
137
+ throw toolError(
138
+ 'Framer refused Dexter’s access. Connect your Framer account again in Dexter.',
139
+ 'FRAMER_ACCOUNT_CONNECTION_REQUIRED',
140
+ { statusCode: 409 },
141
+ );
142
+ }
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ // An expired session is refused before the program runs, so reopening the
148
+ // project and retrying once cannot apply a change twice.
149
+ async function call(name, args, retried = false) {
150
+ let result;
151
+ try {
152
+ result = unwrap(await rawCall(name, args));
153
+ } catch (error) {
154
+ const expired = error?.code === 'FRAMER_MCP_SESSION_EXPIRED'
155
+ || SESSION_EXPIRED_PATTERN.test(String(error?.message || ''));
156
+ if (!expired || retried || !projectId || name === 'open_project') throw error;
157
+ await openProject(projectId);
158
+ return call(name, { ...args, session }, true);
159
+ }
160
+ return result;
161
+ }
162
+
163
+ async function openProject(id) {
164
+ const result = await call('open_project', { projectId: id });
165
+ const nextSession = sessionFromOpenResult(result);
166
+ if (!nextSession) {
167
+ throw toolError('Framer did not open a project session.', 'FRAMER_MCP_SESSION_MISSING');
168
+ }
169
+ session = nextSession;
170
+ projectId = id;
171
+ readme = readmeFromOpenResult(result);
172
+ return { session, readme, structured: result.structured };
173
+ }
174
+
175
+ function requireSession() {
176
+ if (!session) throw toolError('Framer project is not open.', 'FRAMER_MCP_SESSION_MISSING');
177
+ }
178
+
179
+ return {
180
+ openProject,
181
+ readme: () => readme,
182
+ session: () => session,
183
+ projectId: () => projectId,
184
+ async exec(program) {
185
+ requireSession();
186
+ return call('exec', { session, program });
187
+ },
188
+ async readDocs(paths) {
189
+ requireSession();
190
+ const list = (Array.isArray(paths) ? paths : [paths]).filter((value) => typeof value === 'string' && value.trim());
191
+ const pages = [];
192
+ for (const docPath of list) {
193
+ const result = await call('read_docs', { session, path: docPath });
194
+ const content = result.structured.content
195
+ || result.structured.markdown
196
+ || result.structured.text
197
+ || result.text
198
+ || '';
199
+ pages.push({ path: docPath, content: String(content) });
200
+ }
201
+ return pages;
202
+ },
203
+ async viewImage(urls) {
204
+ requireSession();
205
+ return call('view_image', { session, urls });
206
+ },
207
+ async close() {
208
+ session = null;
209
+ if (transport?.close) await transport.close().catch(() => undefined);
210
+ transport = null;
211
+ sdkClient = null;
212
+ },
213
+ };
214
+ }