@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 +15 -26
- package/package.json +1 -2
- package/src/agent.js +25 -35
- 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 +1253 -0
- package/src/outcomeWorkspace.js +4 -0
- package/src/protocol.js +46 -22
- package/src/providers/codexAppServer.js +2 -5
- package/src/framerAgentTools.js +0 -3298
- 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.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 './
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
3013
|
-
? 'I’m
|
|
3014
|
-
:
|
|
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,
|
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
|
+
}
|