@gakim-digital/dexter-bridge 0.11.24 → 0.12.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.
- package/README.md +15 -26
- package/package.json +1 -2
- package/src/agent.js +12 -29
- package/src/agentOutput.js +1 -1
- package/src/api.js +0 -35
- package/src/config.js +3 -2
- package/src/framerBehaviorContract.js +1 -90
- package/src/framerMcpClient.js +214 -0
- package/src/framerTools.js +1297 -0
- package/src/outcomeWorkspace.js +4 -0
- package/src/protocol.js +34 -22
- package/src/providers/codexAppServer.js +2 -5
- package/src/framerAgentTools.js +0 -3338
- package/src/googleFontsFallback.js +0 -196
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
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
- `
|
|
42
|
-
- `
|
|
43
|
-
-
|
|
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
|
-
|
|
59
|
+
Account connection is handled inside Dexter and completes before the model
|
|
61
60
|
starts:
|
|
62
61
|
|
|
63
|
-
- The
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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.
|
|
3
|
+
"version": "0.12.1",
|
|
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 './
|
|
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
|
-
'
|
|
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-
|
|
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
|
-
|
|
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,
|
|
@@ -2998,6 +2973,9 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
|
|
|
2998
2973
|
) {
|
|
2999
2974
|
synchronization = await harnessTools.synchronize();
|
|
3000
2975
|
}
|
|
2976
|
+
if (typeof harnessTools.assessCompletion === 'function') {
|
|
2977
|
+
await harnessTools.assessCompletion(rawCompletion);
|
|
2978
|
+
}
|
|
3001
2979
|
const unappliedChangePrompt =
|
|
3002
2980
|
rawCompletion.status === successStatus
|
|
3003
2981
|
? harnessTools.unappliedChangePrompt?.(rawCompletion) || null
|
|
@@ -3059,6 +3037,9 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
|
|
|
3059
3037
|
updatedAt: Date.now(),
|
|
3060
3038
|
});
|
|
3061
3039
|
}
|
|
3040
|
+
if (typeof harnessTools.assessCompletion === 'function') {
|
|
3041
|
+
await harnessTools.assessCompletion(rawCompletion);
|
|
3042
|
+
}
|
|
3062
3043
|
const completion =
|
|
3063
3044
|
typeof harnessTools.finalizeCompletion === 'function'
|
|
3064
3045
|
? harnessTools.finalizeCompletion(rawCompletion)
|
|
@@ -3162,6 +3143,7 @@ export async function executeRun(
|
|
|
3162
3143
|
inspectAgentAuthentication,
|
|
3163
3144
|
runtimeProfile: suppliedRuntimeProfile,
|
|
3164
3145
|
framerAgentRunCli,
|
|
3146
|
+
framerMcpClient,
|
|
3165
3147
|
framerScreenshotFetch,
|
|
3166
3148
|
allowInsecureHttp = false,
|
|
3167
3149
|
} = {},
|
|
@@ -3282,6 +3264,7 @@ export async function executeRun(
|
|
|
3282
3264
|
product,
|
|
3283
3265
|
runtimeProfile,
|
|
3284
3266
|
framerAgentRunCli,
|
|
3267
|
+
framerMcpClient,
|
|
3285
3268
|
framerScreenshotFetch,
|
|
3286
3269
|
apiBaseUrl,
|
|
3287
3270
|
deviceToken,
|
package/src/agentOutput.js
CHANGED
|
@@ -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 (
|
|
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
|
-
'./
|
|
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-
|
|
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
|
|
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
|
+
}
|