premanmcp 1.1.3 → 1.1.5
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/bin/eval.js +205 -23
- package/bin/eval_target.js +67 -20
- package/package.json +1 -1
- package/dist/auth_flow_ui.d.ts +0 -38
- package/dist/auth_flow_ui.js +0 -170
- package/dist/mcp-preview-panel.d.ts +0 -30
- package/dist/mcp-preview-panel.js +0 -166
- package/dist/user_auth_flow.d.ts +0 -11
- package/dist/user_auth_flow.js +0 -279
package/bin/eval.js
CHANGED
|
@@ -21,15 +21,25 @@
|
|
|
21
21
|
* run — cancelled, or taken over — and the only correct response is to stop.
|
|
22
22
|
* - The runner token never enters the child's environment.
|
|
23
23
|
*
|
|
24
|
-
* One rule is this module's own: the run executes
|
|
25
|
-
*
|
|
26
|
-
* that scattered `artifacts/` into somebody's repository would be a bad
|
|
27
|
-
* even before the first `git status`.
|
|
24
|
+
* One rule is this module's own: the run executes outside the customer's
|
|
25
|
+
* project, under `~/.preman/evals`. assert-ai writes results under `cwd`, and a
|
|
26
|
+
* harness that scattered `artifacts/` into somebody's repository would be a bad
|
|
27
|
+
* guest even before the first `git status`. That directory is kept between runs
|
|
28
|
+
* rather than thrown away — see `suiteHome` for what is stored there and why
|
|
29
|
+
* deleting it was the reason every run measured different cases.
|
|
28
30
|
*/
|
|
29
31
|
|
|
30
32
|
import { spawn, spawnSync } from "node:child_process";
|
|
31
33
|
import { createHash } from "node:crypto";
|
|
32
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
existsSync,
|
|
36
|
+
mkdirSync,
|
|
37
|
+
readdirSync,
|
|
38
|
+
readFileSync,
|
|
39
|
+
rmSync,
|
|
40
|
+
statSync,
|
|
41
|
+
writeFileSync,
|
|
42
|
+
} from "node:fs";
|
|
33
43
|
import os from "node:os";
|
|
34
44
|
import path from "node:path";
|
|
35
45
|
import { fileURLToPath } from "node:url";
|
|
@@ -75,10 +85,114 @@ const MAX_STDERR_BYTES = 256 * 1024;
|
|
|
75
85
|
|
|
76
86
|
export class EvalError extends Error {}
|
|
77
87
|
|
|
88
|
+
// ── Where a suite lives between runs ────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/** Everything this machine keeps about evals it has run. */
|
|
91
|
+
const EVAL_HOME = path.join(os.homedir(), ".preman", "evals");
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Upstream's own rule for a suite id, copied rather than referenced.
|
|
95
|
+
*
|
|
96
|
+
* `assert_ai.config._validate_identifier` applies this to `suite:` before it
|
|
97
|
+
* builds a path from it. Applying it again here is not distrust of the backend
|
|
98
|
+
* but of the join: this is the one place a value off the network becomes a
|
|
99
|
+
* directory name in the user's home.
|
|
100
|
+
*/
|
|
101
|
+
const SAFE_SUITE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
102
|
+
|
|
103
|
+
/** Finished runs kept per suite. Enough to compare against last week. */
|
|
104
|
+
const KEEP_RUNS = 20;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The directory this suite runs in — the same one, every time.
|
|
108
|
+
*
|
|
109
|
+
* This being stable is the whole reason a rerun measures the same cases as the
|
|
110
|
+
* run before it. assert-ai caches its two generative stages: `systematize` and
|
|
111
|
+
* `test_set` write into `<suite>/artifacts/<stage>/v0001, v0002, ...` beside a
|
|
112
|
+
* hash of what produced them, and on the next run it re-derives that hash and
|
|
113
|
+
* reuses the matching version instead of generating again. The hash covers the
|
|
114
|
+
* behaviour text, the stage config and the taxonomy the test set was built
|
|
115
|
+
* from, so an edit to any of those regenerates and nothing else does.
|
|
116
|
+
*
|
|
117
|
+
* That machinery was already there and we were defeating it. The run used to
|
|
118
|
+
* execute in a fresh `mkdtemp` that was deleted on the way out, so the lookup
|
|
119
|
+
* had an empty disk to search and missed every time — which is why two runs of
|
|
120
|
+
* an unchanged behaviour never scored the same cases, and why nothing here was
|
|
121
|
+
* comparable to anything.
|
|
122
|
+
*
|
|
123
|
+
* Deliberately not the customer's project, and deliberately not keyed by it
|
|
124
|
+
* either: the bank belongs to the behaviour, and running the same eval from a
|
|
125
|
+
* second checkout should reuse it rather than pay to invent a parallel one.
|
|
126
|
+
*/
|
|
127
|
+
export function suiteHome(job, { home = EVAL_HOME } = {}) {
|
|
128
|
+
const suite = String(job?.suite ?? "");
|
|
129
|
+
if (suite.length > 255 || suite.includes("..") || !SAFE_SUITE.test(suite)) {
|
|
130
|
+
throw new EvalError(
|
|
131
|
+
`this run names a suite that cannot be a directory: ${JSON.stringify(suite)}`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const directory = path.join(home, suite);
|
|
135
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
136
|
+
return directory;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Drop the oldest finished runs, keeping the bank.
|
|
141
|
+
*
|
|
142
|
+
* Run outputs are transcripts and scores and they accumulate without limit;
|
|
143
|
+
* the cached stages next to them are the reason this directory is kept at all.
|
|
144
|
+
* So the rule is by name, not by age alone: `artifacts/` is never a candidate
|
|
145
|
+
* however old it looks, and everything else under the suite is a run.
|
|
146
|
+
*
|
|
147
|
+
* Called before the run starts rather than after it finishes, so that nothing
|
|
148
|
+
* being deleted here can be a run that is still being uploaded.
|
|
149
|
+
*/
|
|
150
|
+
export function pruneRuns(home, job, { keep = KEEP_RUNS } = {}) {
|
|
151
|
+
const results = path.join(home, "artifacts", "results", String(job.suite));
|
|
152
|
+
let entries;
|
|
153
|
+
try {
|
|
154
|
+
entries = readdirSync(results, { withFileTypes: true });
|
|
155
|
+
} catch {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const runs = [];
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
if (!entry.isDirectory() || entry.name === "artifacts") continue;
|
|
162
|
+
const full = path.join(results, entry.name);
|
|
163
|
+
try {
|
|
164
|
+
runs.push({ full, at: statSync(full).mtimeMs });
|
|
165
|
+
} catch {
|
|
166
|
+
// Vanished between the listing and the stat. Nothing to prune.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const doomed = runs.sort((a, b) => b.at - a.at).slice(keep);
|
|
171
|
+
for (const run of doomed) rmSync(run.full, { recursive: true, force: true });
|
|
172
|
+
return doomed.map((run) => run.full);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* What a finished run leaves behind, and what it must not.
|
|
177
|
+
*
|
|
178
|
+
* The bundle goes. It is whatever the frame carried — a spec, and sandbox or
|
|
179
|
+
* fixture files that can hold a URL and a credential for the customer's own
|
|
180
|
+
* systems — and when this ran in a temp directory that was deleted on the way
|
|
181
|
+
* out, none of it outlived the run. Keeping the directory must not quietly
|
|
182
|
+
* turn that into a token sitting in `~/.preman` until someone notices.
|
|
183
|
+
*
|
|
184
|
+
* The bank and the results stay. Those are the point: generated cases to reuse
|
|
185
|
+
* and scores to compare against.
|
|
186
|
+
*/
|
|
187
|
+
function tidy(home) {
|
|
188
|
+
if (!home) return;
|
|
189
|
+
rmSync(path.join(home, "bundle"), { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
|
|
78
192
|
// ── The bundle on disk ──────────────────────────────────────────────────
|
|
79
193
|
|
|
80
194
|
/**
|
|
81
|
-
* Write the leased run into
|
|
195
|
+
* Write the leased run into its suite's directory and return what to run.
|
|
82
196
|
*
|
|
83
197
|
* Every path in the frame is checked before it is joined, not because the
|
|
84
198
|
* backend is untrusted but because "the server would never send that" is the
|
|
@@ -88,6 +202,11 @@ export class EvalError extends Error {}
|
|
|
88
202
|
export function materialize(job, root) {
|
|
89
203
|
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
90
204
|
const bundle = path.join(root, "bundle");
|
|
205
|
+
// Emptied first, because the directory outlives the run now. Every file the
|
|
206
|
+
// spec refers to arrives in the frame, so anything already here came from a
|
|
207
|
+
// previous run — and a sandbox or fixture the behaviour has since dropped
|
|
208
|
+
// would otherwise still be sitting where the spec used to point.
|
|
209
|
+
rmSync(bundle, { recursive: true, force: true });
|
|
91
210
|
mkdirSync(bundle, { recursive: true, mode: 0o700 });
|
|
92
211
|
|
|
93
212
|
for (const entry of job.files || []) {
|
|
@@ -306,9 +425,10 @@ function readJson(file) {
|
|
|
306
425
|
/**
|
|
307
426
|
* Where this run's output lands.
|
|
308
427
|
*
|
|
309
|
-
* `artifacts_base()` upstream is `Path.cwd()`, so this is a consequence of
|
|
310
|
-
*
|
|
311
|
-
*
|
|
428
|
+
* `artifacts_base()` upstream is `Path.cwd()`, so this is a consequence of
|
|
429
|
+
* where the run is executed rather than a configuration choice — which is why
|
|
430
|
+
* `suiteHome` is what it is. The sibling of this path is the cached bank the
|
|
431
|
+
* same suite reuses next time.
|
|
312
432
|
*/
|
|
313
433
|
export function runDir(cwd, job) {
|
|
314
434
|
return path.join(cwd, "artifacts", "results", String(job.suite), String(job.run));
|
|
@@ -455,6 +575,58 @@ export function readProgress(cwd, job) {
|
|
|
455
575
|
};
|
|
456
576
|
}
|
|
457
577
|
|
|
578
|
+
/**
|
|
579
|
+
* Whether the tool calls the agent made are in the transcript the judge read.
|
|
580
|
+
*
|
|
581
|
+
* A behaviour like `unchecked_account_claim` is defined entirely in terms of
|
|
582
|
+
* tool calls: its taxonomy tells the judge to read the events and not to infer
|
|
583
|
+
* a lookup from the wording of a reply. So a transcript that lost them is not a
|
|
584
|
+
* degraded measurement, it is the opposite measurement -- an agent that checked
|
|
585
|
+
* before answering is indistinguishable from one that made the figure up, and
|
|
586
|
+
* the run reports the second. It happened for every run of that suite until the
|
|
587
|
+
* adapter's event field names were corrected, and it exited 0 each time.
|
|
588
|
+
*
|
|
589
|
+
* Compared against the adapter's own count rather than checked for zero on its
|
|
590
|
+
* own, because zero tool calls is a legitimate result: an agent asked a general
|
|
591
|
+
* question should not call anything. The failure is the disagreement.
|
|
592
|
+
*
|
|
593
|
+
* Returns "" when there is nothing wrong, so the caller can tell "fine" from
|
|
594
|
+
* "unreadable".
|
|
595
|
+
*/
|
|
596
|
+
export function missingToolEvidence(dir, stats) {
|
|
597
|
+
const forwarded = Number(stats?.toolCalls);
|
|
598
|
+
if (!Number.isFinite(forwarded) || forwarded <= 0) return "";
|
|
599
|
+
|
|
600
|
+
let recorded = 0;
|
|
601
|
+
try {
|
|
602
|
+
const text = readFileSync(path.join(dir, "inference_set.jsonl"), "utf8");
|
|
603
|
+
for (const line of text.split("\n")) {
|
|
604
|
+
if (!line.trim()) continue;
|
|
605
|
+
let row;
|
|
606
|
+
try {
|
|
607
|
+
row = JSON.parse(line);
|
|
608
|
+
} catch {
|
|
609
|
+
continue; // A row we cannot read is not evidence that evidence is missing.
|
|
610
|
+
}
|
|
611
|
+
for (const event of row.events || []) {
|
|
612
|
+
if (event?.edit?.type === "tool_call") recorded += 1;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
} catch {
|
|
616
|
+
// No transcript to read is a different failure, and the harness's own exit
|
|
617
|
+
// code reports it. Claiming this one on top would be guessing.
|
|
618
|
+
return "";
|
|
619
|
+
}
|
|
620
|
+
if (recorded > 0) return "";
|
|
621
|
+
|
|
622
|
+
return (
|
|
623
|
+
`the agent made ${forwarded} tool call(s) and none of them reached the transcript, so ` +
|
|
624
|
+
`the judge scored it as an agent that called nothing. Every case that answered from a ` +
|
|
625
|
+
`lookup would be marked unsupported. This is the adapter's event channel, not the ` +
|
|
626
|
+
`agent: check that the events it sends still match the fields assert-ai reads.`
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
458
630
|
// ── Handing the run over to a surface ───────────────────────────────────
|
|
459
631
|
|
|
460
632
|
/** How long to hold a leased run waiting for somebody to be watching it. */
|
|
@@ -652,12 +824,13 @@ export async function executeEvalRun(
|
|
|
652
824
|
return { ok, reason: result.ok ? "reported" : "report_failed" };
|
|
653
825
|
};
|
|
654
826
|
|
|
655
|
-
let
|
|
827
|
+
let home = "";
|
|
656
828
|
let laid;
|
|
657
829
|
let python;
|
|
658
830
|
try {
|
|
659
|
-
|
|
660
|
-
|
|
831
|
+
home = suiteHome(job);
|
|
832
|
+
pruneRuns(home, job);
|
|
833
|
+
laid = materialize(job, home);
|
|
661
834
|
python = resolveInterpreter({ projectPath, log });
|
|
662
835
|
log(`eval ${job.id}: assert-ai ${python.version} via ${python.how}`);
|
|
663
836
|
} catch (error) {
|
|
@@ -665,12 +838,12 @@ export async function executeEvalRun(
|
|
|
665
838
|
// amount of retrying changes it. Failing with the reason is more use than
|
|
666
839
|
// letting the lease lapse and having the server call it a lost device.
|
|
667
840
|
log(`eval ${job.id} cannot run here: ${error.message}`);
|
|
668
|
-
|
|
841
|
+
tidy(home);
|
|
669
842
|
return finish(false, { error: error.message });
|
|
670
843
|
}
|
|
671
844
|
|
|
672
845
|
if (!existsSync(HARNESS_PATH)) {
|
|
673
|
-
|
|
846
|
+
tidy(home);
|
|
674
847
|
return finish(false, { error: `the eval harness is missing from this install (${HARNESS_PATH})` });
|
|
675
848
|
}
|
|
676
849
|
|
|
@@ -680,13 +853,13 @@ export async function executeEvalRun(
|
|
|
680
853
|
// which reports it from inside `inference` as an authentication error
|
|
681
854
|
// against a model name -- true, and no use to somebody who simply has not
|
|
682
855
|
// saved a key anywhere.
|
|
683
|
-
|
|
856
|
+
tidy(home);
|
|
684
857
|
return finish(false, { error: missingKey });
|
|
685
858
|
}
|
|
686
859
|
|
|
687
860
|
const handoff = await awaitAudience(job, { call, log, lease, headless, surface });
|
|
688
861
|
if (handoff.lost) {
|
|
689
|
-
|
|
862
|
+
tidy(home);
|
|
690
863
|
return { ok: false, reason: "lease_lost" };
|
|
691
864
|
}
|
|
692
865
|
|
|
@@ -774,8 +947,8 @@ export async function executeEvalRun(
|
|
|
774
947
|
clearInterval(heartbeat);
|
|
775
948
|
clearTimeout(killer);
|
|
776
949
|
|
|
777
|
-
// Deliberately before the
|
|
778
|
-
//
|
|
950
|
+
// Deliberately before the bundle is removed: the summary is read off the
|
|
951
|
+
// disk the run just wrote to.
|
|
779
952
|
const summary = readJson(path.join(runDir(laid.cwd, job), "metrics.json")) || {};
|
|
780
953
|
const artifacts = runDir(laid.cwd, job);
|
|
781
954
|
|
|
@@ -783,7 +956,7 @@ export async function executeEvalRun(
|
|
|
783
956
|
// No completion callback and no final sync. The run belongs to something
|
|
784
957
|
// else now, and this device reporting a result for it — or writing over its
|
|
785
958
|
// artifacts — is the exact thing the fencing token exists to prevent.
|
|
786
|
-
|
|
959
|
+
tidy(home);
|
|
787
960
|
return { ok: false, reason: "lease_lost" };
|
|
788
961
|
}
|
|
789
962
|
|
|
@@ -794,7 +967,7 @@ export async function executeEvalRun(
|
|
|
794
967
|
// whose results are already readable.
|
|
795
968
|
const final = await syncArtifacts(job, laid.cwd, sent, { call, log, lease });
|
|
796
969
|
if (final.lost) {
|
|
797
|
-
|
|
970
|
+
tidy(home);
|
|
798
971
|
return { ok: false, reason: "lease_lost" };
|
|
799
972
|
}
|
|
800
973
|
|
|
@@ -818,12 +991,21 @@ export async function executeEvalRun(
|
|
|
818
991
|
`measured against it. The last reason was: ${stats.lastFailure || "unknown"}`,
|
|
819
992
|
exitCode,
|
|
820
993
|
});
|
|
821
|
-
|
|
994
|
+
tidy(home);
|
|
995
|
+
return { ...outcome, summary, artifacts };
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// The same shape of failure one layer in: the agent was reached, it acted,
|
|
999
|
+
// and the record of it acting did not survive the trip to the judge.
|
|
1000
|
+
const blind = missingToolEvidence(artifacts, stats);
|
|
1001
|
+
if (blind) {
|
|
1002
|
+
const outcome = await finish(false, { summary, error: blind, exitCode });
|
|
1003
|
+
tidy(home);
|
|
822
1004
|
return { ...outcome, summary, artifacts };
|
|
823
1005
|
}
|
|
824
1006
|
|
|
825
1007
|
const outcome = await finish(true, { summary, exitCode });
|
|
826
|
-
|
|
1008
|
+
tidy(home);
|
|
827
1009
|
return { ...outcome, summary, artifacts };
|
|
828
1010
|
}
|
|
829
1011
|
|
|
@@ -833,7 +1015,7 @@ export async function executeEvalRun(
|
|
|
833
1015
|
error: `the eval harness exited ${exitCode}: ${tail}`,
|
|
834
1016
|
exitCode,
|
|
835
1017
|
});
|
|
836
|
-
|
|
1018
|
+
tidy(home);
|
|
837
1019
|
return { ...outcome, summary, artifacts };
|
|
838
1020
|
}
|
|
839
1021
|
|
package/bin/eval_target.js
CHANGED
|
@@ -293,7 +293,7 @@ function readBody(request) {
|
|
|
293
293
|
/**
|
|
294
294
|
* One turn against PreMan's own agent.
|
|
295
295
|
*
|
|
296
|
-
* Tool calls come back in `turn.
|
|
296
|
+
* Tool calls come back in `turn.tool_calls` and are forwarded as adapter-shaped
|
|
297
297
|
* `events`, which is the difference between the judge seeing a transcript and
|
|
298
298
|
* the judge seeing what the agent *did*. `HTTPEndpointSession` promotes those to
|
|
299
299
|
* first-class interaction messages; without them a tool-using agent is scored on
|
|
@@ -443,32 +443,78 @@ async function premanTurn(target, args, { message, history }, state) {
|
|
|
443
443
|
.digest("hex");
|
|
444
444
|
state.conversations.set(nextKey, conversationId);
|
|
445
445
|
|
|
446
|
-
|
|
446
|
+
// Counted here rather than derived from the artifacts later, because these
|
|
447
|
+
// two numbers answer different questions: this is what the agent did, and
|
|
448
|
+
// what the transcript holds is what the judge got to see. A run where they
|
|
449
|
+
// disagree is the failure this counter exists to make visible.
|
|
450
|
+
//
|
|
451
|
+
// Counting the events rather than `turn.tool_calls` keeps it a count of what
|
|
452
|
+
// was forwarded: a call this adapter dropped is not one the transcript is
|
|
453
|
+
// missing. `tool_result` is one call, per `eventsFrom`.
|
|
454
|
+
const events = eventsFrom(turn);
|
|
455
|
+
state.toolCalls += events.filter((event) => event.role === "tool_result").length;
|
|
456
|
+
|
|
457
|
+
return { response: reply, events };
|
|
447
458
|
}
|
|
448
459
|
|
|
449
460
|
/**
|
|
450
|
-
*
|
|
461
|
+
* What the turn did, as the events assert-ai understands.
|
|
451
462
|
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
463
|
+
* Read from `turn.tool_calls`, which the backend records by name. It used to
|
|
464
|
+
* be inferred from `turn.artifacts` and that never worked: artifacts are keyed
|
|
465
|
+
* by the UI card they render rather than by the tool that produced them, no
|
|
466
|
+
* artifact anywhere carried a tool name, and the loop that looked for one
|
|
467
|
+
* returned an empty array on every turn ever scored. A judge asked whether the
|
|
468
|
+
* agent checked before answering was reading a transcript in which checking
|
|
469
|
+
* was invisible.
|
|
470
|
+
*
|
|
471
|
+
* The field names are assert-ai's `AdapterEvent`, and they are the whole
|
|
472
|
+
* difference between an event and nothing. Its normalizer keys on `role` and
|
|
473
|
+
* skips any event whose role is not one of its three, without logging -- so a
|
|
474
|
+
* plausible-looking `{type, name, arguments}` is dropped silently and produces
|
|
475
|
+
* exactly the same transcript as an agent that called nothing. Every run of
|
|
476
|
+
* `unchecked_account_claim` before this was scored against one: 24 cases, zero
|
|
477
|
+
* tool calls recorded, while the agent was answering with live figures that
|
|
478
|
+
* matched the workspace.
|
|
479
|
+
*
|
|
480
|
+
* One `tool_result` per call, and deliberately no `tool_call` event alongside
|
|
481
|
+
* it. `tool_result` is the one that becomes a tool call in the artifacts: a
|
|
482
|
+
* `tool_call` event is an assistant message carrying `tool_calls`, which the
|
|
483
|
+
* inference stage only remembers, while the `ToolCallEdit` that
|
|
484
|
+
* `edit.type == "tool_call"` refers to -- what the judge and the optimizer both
|
|
485
|
+
* count -- is written when the tool *message* arrives. Sending both records the
|
|
486
|
+
* same single call and adds an assistant message with empty content for each
|
|
487
|
+
* one, because the endpoint session attaches a `raw` to every event and the
|
|
488
|
+
* stage keeps any message that has one. Blank assistant turns are the last
|
|
489
|
+
* thing to show a judge deciding whether the agent answered from a lookup.
|
|
490
|
+
*
|
|
491
|
+
* Whether the tool ran travels in the result's text, in the taxonomy's own
|
|
492
|
+
* words, because text is what survives: of everything an event can carry, only
|
|
493
|
+
* `content`, `tool_name`, `tool_args` and `tool_call_id` reach the artifacts. A
|
|
494
|
+
* risky tool is not run — the broker records a proposal and waits for a person —
|
|
495
|
+
* and a judge that read the request as the deed would score a deletion that
|
|
496
|
+
* never occurred. What the tool returned is not carried at all: the backend's
|
|
497
|
+
* record is name, arguments and executed, which is what "did it look this up
|
|
498
|
+
* before answering" needs and no more.
|
|
457
499
|
*/
|
|
458
|
-
function eventsFrom(turn) {
|
|
459
|
-
const
|
|
500
|
+
export function eventsFrom(turn) {
|
|
501
|
+
const calls = Array.isArray(turn.tool_calls) ? turn.tool_calls : [];
|
|
460
502
|
const events = [];
|
|
461
|
-
for (const
|
|
462
|
-
const name =
|
|
503
|
+
for (const call of calls) {
|
|
504
|
+
const name = String(call?.name || "");
|
|
463
505
|
if (!name) continue;
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
506
|
+
const args = call.arguments;
|
|
507
|
+
events.push({
|
|
508
|
+
role: "tool_result",
|
|
509
|
+
content:
|
|
510
|
+
call.executed === false
|
|
511
|
+
? `executed: false — ${name} is a risky action held for the user's approval. It ` +
|
|
512
|
+
`did not run and returned no data.`
|
|
513
|
+
: `executed: true — ${name} ran and returned its result to the agent. The result ` +
|
|
514
|
+
`itself is not carried into this transcript.`,
|
|
515
|
+
tool_name: name,
|
|
516
|
+
tool_args: args && typeof args === "object" && !Array.isArray(args) ? args : {},
|
|
517
|
+
});
|
|
472
518
|
}
|
|
473
519
|
return events;
|
|
474
520
|
}
|
|
@@ -504,6 +550,7 @@ export async function startAdapter(target, args, { log = () => {}, onStep = null
|
|
|
504
550
|
failures: 0,
|
|
505
551
|
lastFailure: "",
|
|
506
552
|
streamFailures: 0,
|
|
553
|
+
toolCalls: 0,
|
|
507
554
|
log,
|
|
508
555
|
/**
|
|
509
556
|
* One thing the agent said it was doing, on its way to the dashboard.
|
package/package.json
CHANGED
package/dist/auth_flow_ui.d.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auth flow endpoints (signup, verify OTP, login, resend OTP) with JSON schemas
|
|
3
|
-
* for share_endpoints_with_ui / agent-sessions push.
|
|
4
|
-
*/
|
|
5
|
-
export type AuthFlowEndpoint = {
|
|
6
|
-
method: string;
|
|
7
|
-
path_template: string;
|
|
8
|
-
description: string;
|
|
9
|
-
tags: string[];
|
|
10
|
-
source_file: string;
|
|
11
|
-
request_body_schema: Record<string, unknown>;
|
|
12
|
-
response_schema: Record<string, unknown>;
|
|
13
|
-
mcp_tool?: string;
|
|
14
|
-
};
|
|
15
|
-
/** Core auth endpoints aligned with routes/auth/routes.py and user_auth_* MCP tools. */
|
|
16
|
-
export declare function buildAuthFlowEndpoints(): AuthFlowEndpoint[];
|
|
17
|
-
export type ShareAuthFlowResult = {
|
|
18
|
-
session_id: string;
|
|
19
|
-
url: string;
|
|
20
|
-
endpoint_count: number;
|
|
21
|
-
user_id: number | null;
|
|
22
|
-
auto_discoverable: boolean;
|
|
23
|
-
upstream_base_url: string;
|
|
24
|
-
endpoints: AuthFlowEndpoint[];
|
|
25
|
-
ui: {
|
|
26
|
-
url: string;
|
|
27
|
-
note: string;
|
|
28
|
-
};
|
|
29
|
-
related_tools: string[];
|
|
30
|
-
};
|
|
31
|
-
export declare function shareAuthFlowToUi(opts: {
|
|
32
|
-
backendUrl: string;
|
|
33
|
-
frontendUrl: string;
|
|
34
|
-
upstreamBaseUrl?: string;
|
|
35
|
-
sessionId?: string;
|
|
36
|
-
intent?: string;
|
|
37
|
-
apiKey?: string;
|
|
38
|
-
}): Promise<ShareAuthFlowResult>;
|
package/dist/auth_flow_ui.js
DELETED
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auth flow endpoints (signup, verify OTP, login, resend OTP) with JSON schemas
|
|
3
|
-
* for share_endpoints_with_ui / agent-sessions push.
|
|
4
|
-
*/
|
|
5
|
-
const EMAIL_PROP = { type: "string", format: "email", description: "User email" };
|
|
6
|
-
const PASSWORD_PROP = {
|
|
7
|
-
type: "string",
|
|
8
|
-
minLength: 6,
|
|
9
|
-
description: "Password (min 6 characters on the server)",
|
|
10
|
-
};
|
|
11
|
-
const OTP_PROP = { type: "string", description: "6-digit code from email" };
|
|
12
|
-
const TOKEN_RESPONSE = {
|
|
13
|
-
type: "object",
|
|
14
|
-
properties: {
|
|
15
|
-
access_token: { type: "string", description: "JWT bearer token" },
|
|
16
|
-
token_type: { type: "string", enum: ["bearer"] },
|
|
17
|
-
user: {
|
|
18
|
-
type: "object",
|
|
19
|
-
properties: {
|
|
20
|
-
id: { type: "string" },
|
|
21
|
-
email: { type: "string", format: "email" },
|
|
22
|
-
},
|
|
23
|
-
required: ["id", "email"],
|
|
24
|
-
},
|
|
25
|
-
},
|
|
26
|
-
required: ["access_token", "token_type", "user"],
|
|
27
|
-
};
|
|
28
|
-
const OTP_SENT_RESPONSE = {
|
|
29
|
-
type: "object",
|
|
30
|
-
properties: {
|
|
31
|
-
message: { type: "string" },
|
|
32
|
-
email_sent: { type: "boolean" },
|
|
33
|
-
},
|
|
34
|
-
required: ["message", "email_sent"],
|
|
35
|
-
};
|
|
36
|
-
/** Core auth endpoints aligned with routes/auth/routes.py and user_auth_* MCP tools. */
|
|
37
|
-
export function buildAuthFlowEndpoints() {
|
|
38
|
-
return [
|
|
39
|
-
{
|
|
40
|
-
method: "POST",
|
|
41
|
-
path_template: "/auth/signup",
|
|
42
|
-
description: "Register with email and password. Sends OTP to email; next: verify-otp.",
|
|
43
|
-
tags: ["auth", "signup"],
|
|
44
|
-
source_file: "routes/auth/routes.py",
|
|
45
|
-
mcp_tool: "user_auth_signup",
|
|
46
|
-
request_body_schema: {
|
|
47
|
-
type: "object",
|
|
48
|
-
properties: { email: EMAIL_PROP, password: PASSWORD_PROP },
|
|
49
|
-
required: ["email", "password"],
|
|
50
|
-
additionalProperties: false,
|
|
51
|
-
},
|
|
52
|
-
response_schema: {
|
|
53
|
-
type: "object",
|
|
54
|
-
properties: {
|
|
55
|
-
message: { type: "string" },
|
|
56
|
-
user_id: { type: "string" },
|
|
57
|
-
email: { type: "string", format: "email" },
|
|
58
|
-
email_sent: { type: "boolean" },
|
|
59
|
-
},
|
|
60
|
-
required: ["message", "user_id", "email", "email_sent"],
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
method: "POST",
|
|
65
|
-
path_template: "/auth/verify-otp",
|
|
66
|
-
description: "Verify email OTP after signup; returns JWT access_token.",
|
|
67
|
-
tags: ["auth", "otp"],
|
|
68
|
-
source_file: "routes/auth/routes.py",
|
|
69
|
-
mcp_tool: "user_auth_verify_otp",
|
|
70
|
-
request_body_schema: {
|
|
71
|
-
type: "object",
|
|
72
|
-
properties: { email: EMAIL_PROP, otp: OTP_PROP },
|
|
73
|
-
required: ["email", "otp"],
|
|
74
|
-
additionalProperties: false,
|
|
75
|
-
},
|
|
76
|
-
response_schema: TOKEN_RESPONSE,
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
method: "POST",
|
|
80
|
-
path_template: "/auth/login",
|
|
81
|
-
description: "Login with email and password. Returns access_token if email is verified.",
|
|
82
|
-
tags: ["auth", "login"],
|
|
83
|
-
source_file: "routes/auth/routes.py",
|
|
84
|
-
mcp_tool: "user_auth_login",
|
|
85
|
-
request_body_schema: {
|
|
86
|
-
type: "object",
|
|
87
|
-
properties: { email: EMAIL_PROP, password: PASSWORD_PROP },
|
|
88
|
-
required: ["email", "password"],
|
|
89
|
-
additionalProperties: false,
|
|
90
|
-
},
|
|
91
|
-
response_schema: TOKEN_RESPONSE,
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
method: "POST",
|
|
95
|
-
path_template: "/auth/resend-otp",
|
|
96
|
-
description: "Send (resend) verification OTP to email.",
|
|
97
|
-
tags: ["auth", "otp"],
|
|
98
|
-
source_file: "routes/auth/routes.py",
|
|
99
|
-
mcp_tool: "user_auth_resend_otp",
|
|
100
|
-
request_body_schema: {
|
|
101
|
-
type: "object",
|
|
102
|
-
properties: { email: EMAIL_PROP },
|
|
103
|
-
required: ["email"],
|
|
104
|
-
additionalProperties: false,
|
|
105
|
-
},
|
|
106
|
-
response_schema: OTP_SENT_RESPONSE,
|
|
107
|
-
},
|
|
108
|
-
];
|
|
109
|
-
}
|
|
110
|
-
export async function shareAuthFlowToUi(opts) {
|
|
111
|
-
const backend = opts.backendUrl.replace(/\/+$/, "");
|
|
112
|
-
const frontend = opts.frontendUrl.replace(/\/+$/, "");
|
|
113
|
-
const apiKey = opts.apiKey?.trim();
|
|
114
|
-
if (!apiKey) {
|
|
115
|
-
throw new Error("PreMan authentication required. Run preman_login first so auth-flow sessions can stream to your dashboard.");
|
|
116
|
-
}
|
|
117
|
-
const upstream = (opts.upstreamBaseUrl || backend).replace(/\/+$/, "") || backend;
|
|
118
|
-
const sessionId = opts.sessionId?.trim() || crypto.randomUUID();
|
|
119
|
-
const endpoints = buildAuthFlowEndpoints().map((ep) => ({
|
|
120
|
-
...ep,
|
|
121
|
-
base_url: upstream,
|
|
122
|
-
}));
|
|
123
|
-
const resp = await fetch(`${backend}/agent-sessions/${encodeURIComponent(sessionId)}/endpoints`, {
|
|
124
|
-
method: "POST",
|
|
125
|
-
headers: {
|
|
126
|
-
"Content-Type": "application/json",
|
|
127
|
-
Accept: "application/json",
|
|
128
|
-
Authorization: `Bearer ${apiKey}`,
|
|
129
|
-
},
|
|
130
|
-
body: JSON.stringify({
|
|
131
|
-
endpoints,
|
|
132
|
-
upstream_base_url: upstream,
|
|
133
|
-
intent: opts.intent || "Auth flow: signup, verify OTP, login, resend OTP",
|
|
134
|
-
client_label: "premanmcp",
|
|
135
|
-
}),
|
|
136
|
-
});
|
|
137
|
-
const text = await resp.text();
|
|
138
|
-
let body = {};
|
|
139
|
-
try {
|
|
140
|
-
body = text ? JSON.parse(text) : {};
|
|
141
|
-
}
|
|
142
|
-
catch {
|
|
143
|
-
throw new Error(`Agent session push failed: ${resp.status} ${text.slice(0, 500)}`);
|
|
144
|
-
}
|
|
145
|
-
if (!resp.ok) {
|
|
146
|
-
throw new Error(String(body.detail ?? body.error ?? `Agent session push failed: ${resp.status}`));
|
|
147
|
-
}
|
|
148
|
-
const sid = String(body.id ?? sessionId);
|
|
149
|
-
const url = `${frontend}/try?session=${encodeURIComponent(sid)}`;
|
|
150
|
-
return {
|
|
151
|
-
session_id: sid,
|
|
152
|
-
url,
|
|
153
|
-
endpoint_count: Number(body.endpoint_count ?? endpoints.length),
|
|
154
|
-
user_id: typeof body.user_id === "number" ? body.user_id : null,
|
|
155
|
-
auto_discoverable: Boolean(body.auto_discoverable),
|
|
156
|
-
upstream_base_url: upstream,
|
|
157
|
-
endpoints: buildAuthFlowEndpoints(),
|
|
158
|
-
ui: {
|
|
159
|
-
url,
|
|
160
|
-
note: "Open in Cursor Agent Browser or the Playground session list. Test signup → verify-otp → login, or resend-otp. " +
|
|
161
|
-
"Schemas are prefilled from routes/auth Pydantic models.",
|
|
162
|
-
},
|
|
163
|
-
related_tools: [
|
|
164
|
-
"user_auth_signup",
|
|
165
|
-
"user_auth_verify_otp",
|
|
166
|
-
"user_auth_login",
|
|
167
|
-
"user_auth_resend_otp",
|
|
168
|
-
],
|
|
169
|
-
};
|
|
170
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
export declare const MCP_PREVIEW_RESOURCE_URI = "ui://preman/mcp-preview";
|
|
2
|
-
export declare const RESOURCE_URI_META_KEY = "ui/resourceUri";
|
|
3
|
-
export declare function escapeHtmlAttr(s: string): string;
|
|
4
|
-
export declare function escapeHtmlText(s: string): string;
|
|
5
|
-
export interface PreviewTool {
|
|
6
|
-
name?: string;
|
|
7
|
-
description?: string;
|
|
8
|
-
inputSchema?: unknown;
|
|
9
|
-
_endpoint_ref?: {
|
|
10
|
-
method?: string;
|
|
11
|
-
path_template?: string;
|
|
12
|
-
tags?: string[];
|
|
13
|
-
source?: string;
|
|
14
|
-
};
|
|
15
|
-
}
|
|
16
|
-
export interface PreviewPayload {
|
|
17
|
-
intent?: string | string[];
|
|
18
|
-
selection_method?: string | string[];
|
|
19
|
-
rationale?: string | string[] | Record<string, string>;
|
|
20
|
-
selected_count?: number;
|
|
21
|
-
spec_preview?: {
|
|
22
|
-
upstream_base_url?: string | string[];
|
|
23
|
-
tools?: PreviewTool[];
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
export declare function buildConversionPanelHtml(data: PreviewPayload): string;
|
|
27
|
-
export declare function writeMcpPreviewFile(panelHtml: string): Promise<{
|
|
28
|
-
absolutePath: string;
|
|
29
|
-
fileUrl: string;
|
|
30
|
-
}>;
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Two-pane HTML for mcp_preview — shared by the MCP stdio server and
|
|
3
|
-
* scripts/emit-mcp-preview.mjs (browser tab fallback when Cursor does not render mcp-app).
|
|
4
|
-
*/
|
|
5
|
-
import fs from "node:fs/promises";
|
|
6
|
-
import path from "node:path";
|
|
7
|
-
import { pathToFileURL } from "node:url";
|
|
8
|
-
export const MCP_PREVIEW_RESOURCE_URI = "ui://preman/mcp-preview";
|
|
9
|
-
export const RESOURCE_URI_META_KEY = "ui/resourceUri";
|
|
10
|
-
export function escapeHtmlAttr(s) {
|
|
11
|
-
return s.replace(/&/g, "&").replace(/"/g, """);
|
|
12
|
-
}
|
|
13
|
-
export function escapeHtmlText(s) {
|
|
14
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
15
|
-
}
|
|
16
|
-
export function buildConversionPanelHtml(data) {
|
|
17
|
-
const toStr = (v) => {
|
|
18
|
-
if (v == null)
|
|
19
|
-
return "";
|
|
20
|
-
if (Array.isArray(v))
|
|
21
|
-
return v.filter((x) => x != null).map((x) => String(x)).join("; ");
|
|
22
|
-
if (typeof v === "object") {
|
|
23
|
-
try {
|
|
24
|
-
return Object.entries(v)
|
|
25
|
-
.map(([k, val]) => `${k}: ${val == null ? "" : String(val)}`)
|
|
26
|
-
.join(" · ");
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
try {
|
|
30
|
-
return JSON.stringify(v);
|
|
31
|
-
}
|
|
32
|
-
catch {
|
|
33
|
-
return String(v);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return String(v);
|
|
38
|
-
};
|
|
39
|
-
const intent = toStr(data.intent).trim() || "(unspecified)";
|
|
40
|
-
const method = toStr(data.selection_method).trim() || "unknown";
|
|
41
|
-
const rationale = toStr(data.rationale).trim();
|
|
42
|
-
const upstream = toStr(data.spec_preview?.upstream_base_url).trim();
|
|
43
|
-
const tools = Array.isArray(data.spec_preview?.tools) ? data.spec_preview.tools : [];
|
|
44
|
-
const selectedCount = typeof data.selected_count === "number" ? data.selected_count : tools.length;
|
|
45
|
-
const linkKey = (m, p) => `${(m || "").toUpperCase()} ${p || ""}`.trim();
|
|
46
|
-
const endpointRows = tools
|
|
47
|
-
.map((t) => {
|
|
48
|
-
const ref = t._endpoint_ref ?? {};
|
|
49
|
-
const m = (ref.method ?? "").toUpperCase();
|
|
50
|
-
const p = ref.path_template ?? "";
|
|
51
|
-
const key = linkKey(m, p);
|
|
52
|
-
const tags = Array.isArray(ref.tags) && ref.tags.length > 0 ? ref.tags.join(", ") : "";
|
|
53
|
-
return `
|
|
54
|
-
<div class="row" data-link="${escapeHtmlAttr(key)}">
|
|
55
|
-
<div class="row-head">
|
|
56
|
-
<span class="method ${escapeHtmlAttr(m)}">${escapeHtmlText(m)}</span>
|
|
57
|
-
<span class="path">${escapeHtmlText(p)}</span>
|
|
58
|
-
</div>
|
|
59
|
-
${tags ? `<div class="row-meta">tags: ${escapeHtmlText(tags)}</div>` : ""}
|
|
60
|
-
</div>`;
|
|
61
|
-
})
|
|
62
|
-
.join("");
|
|
63
|
-
const toolRows = tools
|
|
64
|
-
.map((t) => {
|
|
65
|
-
const ref = t._endpoint_ref ?? {};
|
|
66
|
-
const key = linkKey(ref.method, ref.path_template);
|
|
67
|
-
const schemaJson = (() => {
|
|
68
|
-
try {
|
|
69
|
-
return JSON.stringify(t.inputSchema ?? {}, null, 2);
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
return "{}";
|
|
73
|
-
}
|
|
74
|
-
})();
|
|
75
|
-
return `
|
|
76
|
-
<div class="row" data-link="${escapeHtmlAttr(key)}">
|
|
77
|
-
<div class="tool-name">${escapeHtmlText(t.name ?? "(unnamed)")}</div>
|
|
78
|
-
${t.description ? `<div class="tool-desc">${escapeHtmlText(t.description)}</div>` : ""}
|
|
79
|
-
<pre class="tool-schema">${escapeHtmlText(schemaJson)}</pre>
|
|
80
|
-
</div>`;
|
|
81
|
-
})
|
|
82
|
-
.join("");
|
|
83
|
-
const empty = tools.length === 0
|
|
84
|
-
? `<div class="empty">No matching endpoints. Try a different intent or run <code>verify_endpoints_live</code> first.</div>`
|
|
85
|
-
: "";
|
|
86
|
-
return `<!DOCTYPE html>
|
|
87
|
-
<html lang="en">
|
|
88
|
-
<head>
|
|
89
|
-
<meta charset="UTF-8" />
|
|
90
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
91
|
-
<title>PreMan · MCP Preview</title>
|
|
92
|
-
<style>
|
|
93
|
-
:root { color-scheme: light dark; --bg:#0d1117; --bg2:#161b22; --border:#30363d; --fg:#e6edf3; --muted:#8b949e; --accent:#58a6ff; --green:#2ea043; --orange:#bf8700; --red:#cf222e; --purple:#8957e5; }
|
|
94
|
-
* { margin:0; padding:0; box-sizing:border-box; }
|
|
95
|
-
html, body { height:100%; background:var(--bg); color:var(--fg); font-family:-apple-system,BlinkMacSystemFont,sans-serif; font-size:13px; }
|
|
96
|
-
body { display:flex; flex-direction:column; overflow:hidden; }
|
|
97
|
-
header { padding:10px 14px; border-bottom:1px solid var(--border); background:var(--bg2); flex-shrink:0; }
|
|
98
|
-
header h1 { font-size:13px; font-weight:600; color:var(--accent); }
|
|
99
|
-
header .meta { font-size:11px; color:var(--muted); margin-top:3px; }
|
|
100
|
-
header .meta strong { color:var(--fg); font-weight:600; }
|
|
101
|
-
header .rationale { font-size:11px; color:var(--muted); margin-top:5px; font-style:italic; max-width:900px; }
|
|
102
|
-
.columns { flex:1; display:grid; grid-template-columns:1fr 1fr; gap:1px; background:var(--border); overflow:hidden; min-height:0; }
|
|
103
|
-
.col { background:var(--bg); overflow-y:auto; }
|
|
104
|
-
.col-header { padding:7px 12px; font-size:10px; font-weight:700; letter-spacing:0.06em; text-transform:uppercase; color:var(--muted); border-bottom:1px solid var(--border); position:sticky; top:0; background:var(--bg2); z-index:1; }
|
|
105
|
-
.row { padding:9px 12px; border-bottom:1px solid var(--border); transition:background 0.1s; }
|
|
106
|
-
.row:last-child { border-bottom:none; }
|
|
107
|
-
.row:hover, .row.linked { background:rgba(88,166,255,0.08); }
|
|
108
|
-
.row-head { display:flex; align-items:center; gap:8px; }
|
|
109
|
-
.row-meta { font-size:10px; color:var(--muted); margin-top:3px; padding-left:56px; }
|
|
110
|
-
.method { display:inline-block; min-width:48px; text-align:center; padding:2px 6px; border-radius:3px; font-weight:700; font-size:10px; color:white; font-family:SFMono-Regular,Consolas,monospace; }
|
|
111
|
-
.method.GET{background:var(--accent);} .method.POST{background:var(--green);} .method.PATCH{background:var(--orange);} .method.PUT{background:var(--purple);} .method.DELETE{background:var(--red);}
|
|
112
|
-
.path { font-family:SFMono-Regular,Consolas,monospace; font-size:12px; word-break:break-all; }
|
|
113
|
-
.tool-name { font-family:SFMono-Regular,Consolas,monospace; font-size:12px; font-weight:600; color:var(--accent); }
|
|
114
|
-
.tool-desc { font-size:11px; color:var(--muted); margin-top:3px; line-height:1.4; }
|
|
115
|
-
.tool-schema { font-family:SFMono-Regular,Consolas,monospace; font-size:10px; background:var(--bg2); padding:6px 8px; border-radius:3px; margin-top:6px; white-space:pre-wrap; word-break:break-all; max-height:120px; overflow-y:auto; line-height:1.4; color:var(--muted); }
|
|
116
|
-
.empty { padding:24px; text-align:center; color:var(--muted); font-style:italic; }
|
|
117
|
-
.empty code { font-family:SFMono-Regular,Consolas,monospace; color:var(--accent); background:var(--bg2); padding:1px 5px; border-radius:3px; font-style:normal; }
|
|
118
|
-
footer { padding:8px 14px; border-top:1px solid var(--border); background:var(--bg2); display:flex; gap:10px; align-items:center; flex-shrink:0; font-size:11px; color:var(--muted); }
|
|
119
|
-
footer .upstream { font-family:SFMono-Regular,Consolas,monospace; color:var(--accent); }
|
|
120
|
-
footer .deploy-hint { margin-left:auto; }
|
|
121
|
-
footer .deploy-hint code { font-family:SFMono-Regular,Consolas,monospace; color:var(--fg); background:var(--bg); padding:2px 6px; border-radius:3px; }
|
|
122
|
-
</style>
|
|
123
|
-
</head>
|
|
124
|
-
<body>
|
|
125
|
-
<header>
|
|
126
|
-
<h1>API → MCP Conversion preview</h1>
|
|
127
|
-
<div class="meta">
|
|
128
|
-
Intent: <strong>${escapeHtmlText(intent)}</strong> · Selected <strong>${selectedCount}</strong> endpoint${selectedCount === 1 ? "" : "s"} · Method: ${escapeHtmlText(method)}
|
|
129
|
-
</div>
|
|
130
|
-
${rationale ? `<div class="rationale">${escapeHtmlText(rationale)}</div>` : ""}
|
|
131
|
-
</header>
|
|
132
|
-
<main class="columns">
|
|
133
|
-
<div class="col">
|
|
134
|
-
<div class="col-header">Your API endpoints (${tools.length})</div>
|
|
135
|
-
${endpointRows}${tools.length === 0 ? empty : ""}
|
|
136
|
-
</div>
|
|
137
|
-
<div class="col">
|
|
138
|
-
<div class="col-header">Generated MCP tools (${tools.length})</div>
|
|
139
|
-
${toolRows}${tools.length === 0 ? empty : ""}
|
|
140
|
-
</div>
|
|
141
|
-
</main>
|
|
142
|
-
<footer>
|
|
143
|
-
<span>Upstream: <span class="upstream">${escapeHtmlText(upstream || "(not set)")}</span></span>
|
|
144
|
-
<span class="deploy-hint">Next: ask the agent to call <code>mcp_deploy</code></span>
|
|
145
|
-
</footer>
|
|
146
|
-
<script>
|
|
147
|
-
document.querySelectorAll('.row[data-link]').forEach(row => {
|
|
148
|
-
const key = row.getAttribute('data-link');
|
|
149
|
-
if (!key) return;
|
|
150
|
-
const matches = () => document.querySelectorAll('[data-link="' + CSS.escape(key) + '"]');
|
|
151
|
-
row.addEventListener('mouseenter', () => matches().forEach(r => r.classList.add('linked')));
|
|
152
|
-
row.addEventListener('mouseleave', () => matches().forEach(r => r.classList.remove('linked')));
|
|
153
|
-
});
|
|
154
|
-
</script>
|
|
155
|
-
</body>
|
|
156
|
-
</html>`;
|
|
157
|
-
}
|
|
158
|
-
export async function writeMcpPreviewFile(panelHtml) {
|
|
159
|
-
const outPath = path.join(process.cwd(), "preman-mcp", "mcp-preview-last.html");
|
|
160
|
-
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
161
|
-
await fs.writeFile(outPath, panelHtml, "utf8");
|
|
162
|
-
return {
|
|
163
|
-
absolutePath: outPath,
|
|
164
|
-
fileUrl: pathToFileURL(outPath).href,
|
|
165
|
-
};
|
|
166
|
-
}
|
package/dist/user_auth_flow.d.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* App user auth (JWT) — tools that call the FastAPI backend at PREMAN_BACKEND /auth/*.
|
|
3
|
-
* Separate from preman_login (device flow + pm_live_ API key). No API key is required
|
|
4
|
-
* for these tools; for JWT-protected routes, pass the access_token from login/verify.
|
|
5
|
-
*/
|
|
6
|
-
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
-
/**
|
|
8
|
-
* Register MCP tools for routes/auth (email, OTP, password, JWT).
|
|
9
|
-
* Proxies to the same process as preman-local (PREMAN_BACKEND, e.g. http://127.0.0.1:8000).
|
|
10
|
-
*/
|
|
11
|
-
export declare function registerUserAuthFlowTools(server: McpServer, backendUrl: string): void;
|
package/dist/user_auth_flow.js
DELETED
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
function toolError(message, code = "backend_error", hints) {
|
|
3
|
-
const payload = { error: message, error_code: code };
|
|
4
|
-
if (hints)
|
|
5
|
-
payload._agent_hints = hints;
|
|
6
|
-
return {
|
|
7
|
-
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
8
|
-
isError: true,
|
|
9
|
-
};
|
|
10
|
-
}
|
|
11
|
-
function jsonOk(obj) {
|
|
12
|
-
return { content: [{ type: "text", text: JSON.stringify(obj) }] };
|
|
13
|
-
}
|
|
14
|
-
function normalizeBase(backendUrl) {
|
|
15
|
-
return backendUrl.replace(/\/+$/, "");
|
|
16
|
-
}
|
|
17
|
-
async function callAuthJson(base, method, path, opts) {
|
|
18
|
-
const url = new URL(path.startsWith("/") ? path.slice(1) : path, `${base}/`);
|
|
19
|
-
if (opts?.query) {
|
|
20
|
-
for (const [k, v] of Object.entries(opts.query)) {
|
|
21
|
-
if (v != null && v !== "")
|
|
22
|
-
url.searchParams.set(k, v);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
const headers = { Accept: "application/json" };
|
|
26
|
-
const hasBody = opts?.json != null && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE");
|
|
27
|
-
if (hasBody) {
|
|
28
|
-
headers["Content-Type"] = "application/json";
|
|
29
|
-
}
|
|
30
|
-
if (opts?.token) {
|
|
31
|
-
headers.Authorization = `Bearer ${opts.token.trim()}`;
|
|
32
|
-
}
|
|
33
|
-
const init = { method, headers };
|
|
34
|
-
if (hasBody && opts?.json) {
|
|
35
|
-
init.body = JSON.stringify(opts.json);
|
|
36
|
-
}
|
|
37
|
-
const resp = await fetch(url, init);
|
|
38
|
-
const text = await resp.text();
|
|
39
|
-
let parsed;
|
|
40
|
-
try {
|
|
41
|
-
parsed = text ? JSON.parse(text) : {};
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
parsed = { raw: text };
|
|
45
|
-
}
|
|
46
|
-
const body = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
47
|
-
? parsed
|
|
48
|
-
: { value: parsed };
|
|
49
|
-
return {
|
|
50
|
-
status_code: resp.status,
|
|
51
|
-
ok: resp.ok,
|
|
52
|
-
...body,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Register MCP tools for routes/auth (email, OTP, password, JWT).
|
|
57
|
-
* Proxies to the same process as preman-local (PREMAN_BACKEND, e.g. http://127.0.0.1:8000).
|
|
58
|
-
*/
|
|
59
|
-
export function registerUserAuthFlowTools(server, backendUrl) {
|
|
60
|
-
const base = normalizeBase(backendUrl);
|
|
61
|
-
server.tool("user_auth_start_signup", "Start signup with email only. Sends an OTP; next call user_auth_set_password with email, OTP, and new password. Uses POST /auth/start-signup on PREMAN_BACKEND (no API key). If the backend does not support this endpoint yet, use user_auth_signup instead.", {
|
|
62
|
-
email: z.string().describe("User email"),
|
|
63
|
-
}, async (args) => {
|
|
64
|
-
try {
|
|
65
|
-
const r = await callAuthJson(base, "POST", "/auth/start-signup", {
|
|
66
|
-
json: { email: args.email },
|
|
67
|
-
});
|
|
68
|
-
if (!r.ok) {
|
|
69
|
-
if (r.status_code === 404) {
|
|
70
|
-
return toolError("This backend does not support email-only signup yet. Use user_auth_signup with email and password, then user_auth_verify_otp.", "backend_error", {
|
|
71
|
-
next_actions: ["Call user_auth_signup with email and password.", "Then call user_auth_verify_otp with the email code."],
|
|
72
|
-
related_tools: ["user_auth_signup", "user_auth_verify_otp"],
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
return toolError(String(r.detail ?? r.message ?? "start signup failed"), r.status_code === 400 ? "invalid_input" : "backend_error", {
|
|
76
|
-
next_actions: ["If email exists, use user_auth_login or user_auth_resend_otp."],
|
|
77
|
-
related_tools: ["user_auth_set_password", "user_auth_login"],
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
return jsonOk(r);
|
|
81
|
-
}
|
|
82
|
-
catch (e) {
|
|
83
|
-
const m = e instanceof Error ? e.message : String(e);
|
|
84
|
-
return toolError(m, "backend_error", {
|
|
85
|
-
next_actions: ["Ensure PREMAN_BACKEND is running and JWT_SECRET is set on the server."],
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
server.tool("user_auth_signup", "Register with email and password. Sends an OTP; next call user_auth_verify_otp. Uses POST /auth/signup on PREMAN_BACKEND (no API key).", {
|
|
90
|
-
email: z.string().describe("User email"),
|
|
91
|
-
password: z.string().describe("Password (min 6 characters on the server)"),
|
|
92
|
-
}, async (args) => {
|
|
93
|
-
try {
|
|
94
|
-
const r = await callAuthJson(base, "POST", "/auth/signup", {
|
|
95
|
-
json: { email: args.email, password: args.password },
|
|
96
|
-
});
|
|
97
|
-
if (!r.ok) {
|
|
98
|
-
return toolError(String(r.detail ?? r.message ?? "signup failed"), r.status_code === 400 ? "invalid_input" : "backend_error", {
|
|
99
|
-
next_actions: ["If email exists, use user_auth_login or user_auth_resend_otp."],
|
|
100
|
-
related_tools: ["user_auth_verify_otp", "user_auth_login"],
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
return jsonOk(r);
|
|
104
|
-
}
|
|
105
|
-
catch (e) {
|
|
106
|
-
const m = e instanceof Error ? e.message : String(e);
|
|
107
|
-
return toolError(m, "backend_error", {
|
|
108
|
-
next_actions: ["Ensure PREMAN_BACKEND is running and JWT_SECRET is set on the server."],
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
server.tool("user_auth_verify_otp", "Verify the email OTP and receive access_token (JWT). POST /auth/verify-otp.", { email: z.string(), otp: z.string().describe("6-digit code from email") }, async (args) => {
|
|
113
|
-
try {
|
|
114
|
-
const r = await callAuthJson(base, "POST", "/auth/verify-otp", {
|
|
115
|
-
json: { email: args.email, otp: args.otp },
|
|
116
|
-
});
|
|
117
|
-
if (!r.ok) {
|
|
118
|
-
return toolError(String(r.detail ?? "verify failed"), "auth_required", {
|
|
119
|
-
next_actions: ["Request a new code with user_auth_resend_otp if expired."],
|
|
120
|
-
related_tools: ["user_auth_resend_otp", "user_auth_signup"],
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
return jsonOk(r);
|
|
124
|
-
}
|
|
125
|
-
catch (e) {
|
|
126
|
-
return toolError(e instanceof Error ? e.message : String(e), "backend_error");
|
|
127
|
-
}
|
|
128
|
-
});
|
|
129
|
-
server.tool("user_auth_login", "Login with email and password. Returns access_token if email is verified. POST /auth/login.", { email: z.string(), password: z.string() }, async (args) => {
|
|
130
|
-
try {
|
|
131
|
-
const r = await callAuthJson(base, "POST", "/auth/login", {
|
|
132
|
-
json: { email: args.email, password: args.password },
|
|
133
|
-
});
|
|
134
|
-
if (!r.ok) {
|
|
135
|
-
const sc = r.status_code;
|
|
136
|
-
const code = sc === 403 || sc === 401 ? "auth_required" : "backend_error";
|
|
137
|
-
return toolError(String(r.detail ?? "login failed"), code, {
|
|
138
|
-
next_actions: [
|
|
139
|
-
"If 403 email not verified: use user_auth_verify_otp or user_auth_resend_otp.",
|
|
140
|
-
"If 403 migrated account: use user_auth_forgot_password or user_auth_set_password flow.",
|
|
141
|
-
],
|
|
142
|
-
related_tools: ["user_auth_verify_otp", "user_auth_needs_password", "user_auth_set_password"],
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
return jsonOk(r);
|
|
146
|
-
}
|
|
147
|
-
catch (e) {
|
|
148
|
-
return toolError(e instanceof Error ? e.message : String(e), "backend_error");
|
|
149
|
-
}
|
|
150
|
-
});
|
|
151
|
-
server.tool("user_auth_needs_password", "Check if an account must set a password (e.g. migrated user). GET /auth/needs-password?email=", { email: z.string().optional().describe("Email to check; omit to return false/false from server") }, async (args) => {
|
|
152
|
-
try {
|
|
153
|
-
const r = await callAuthJson(base, "GET", "/auth/needs-password", {
|
|
154
|
-
query: { email: args.email },
|
|
155
|
-
});
|
|
156
|
-
if (!r.ok) {
|
|
157
|
-
return toolError(String(r.detail ?? "request failed"), "backend_error");
|
|
158
|
-
}
|
|
159
|
-
return jsonOk(r);
|
|
160
|
-
}
|
|
161
|
-
catch (e) {
|
|
162
|
-
return toolError(e instanceof Error ? e.message : String(e), "backend_error");
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
server.tool("user_auth_resend_otp", "Resend verification OTP. POST /auth/resend-otp with { email }.", { email: z.string() }, async (args) => {
|
|
166
|
-
try {
|
|
167
|
-
const r = await callAuthJson(base, "POST", "/auth/resend-otp", {
|
|
168
|
-
json: { email: args.email },
|
|
169
|
-
});
|
|
170
|
-
if (!r.ok) {
|
|
171
|
-
return toolError(String(r.detail ?? "resend failed"), "invalid_input");
|
|
172
|
-
}
|
|
173
|
-
return jsonOk(r);
|
|
174
|
-
}
|
|
175
|
-
catch (e) {
|
|
176
|
-
return toolError(e instanceof Error ? e.message : String(e), "backend_error");
|
|
177
|
-
}
|
|
178
|
-
});
|
|
179
|
-
server.tool("user_auth_forgot_password", "Request password reset OTP. POST /auth/forgot-password with { email }.", { email: z.string() }, async (args) => {
|
|
180
|
-
try {
|
|
181
|
-
const r = await callAuthJson(base, "POST", "/auth/forgot-password", {
|
|
182
|
-
json: { email: args.email },
|
|
183
|
-
});
|
|
184
|
-
if (!r.ok) {
|
|
185
|
-
return toolError(String(r.detail ?? "forgot failed"), "backend_error");
|
|
186
|
-
}
|
|
187
|
-
return jsonOk(r);
|
|
188
|
-
}
|
|
189
|
-
catch (e) {
|
|
190
|
-
return toolError(e instanceof Error ? e.message : String(e), "backend_error");
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
server.tool("user_auth_set_password", "Set a new password using OTP (migrated / forgot flow). Returns access_token. POST /auth/set-password.", {
|
|
194
|
-
email: z.string(),
|
|
195
|
-
otp: z.string(),
|
|
196
|
-
new_password: z.string().min(6),
|
|
197
|
-
}, async (args) => {
|
|
198
|
-
try {
|
|
199
|
-
const r = await callAuthJson(base, "POST", "/auth/set-password", {
|
|
200
|
-
json: {
|
|
201
|
-
email: args.email,
|
|
202
|
-
otp: args.otp,
|
|
203
|
-
new_password: args.new_password,
|
|
204
|
-
},
|
|
205
|
-
});
|
|
206
|
-
if (!r.ok) {
|
|
207
|
-
return toolError(String(r.detail ?? "set password failed"), "auth_required", {
|
|
208
|
-
next_actions: ["Request a new OTP with user_auth_forgot_password or user_auth_resend_otp."],
|
|
209
|
-
related_tools: ["user_auth_forgot_password"],
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
return jsonOk(r);
|
|
213
|
-
}
|
|
214
|
-
catch (e) {
|
|
215
|
-
return toolError(e instanceof Error ? e.message : String(e), "backend_error");
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
server.tool("user_auth_me", "Current user profile (JWT). GET /auth/me with Authorization: Bearer access_token from login/verify.", {
|
|
219
|
-
access_token: z.string().describe("JWT from user_auth_login or user_auth_verify_otp"),
|
|
220
|
-
}, async (args) => {
|
|
221
|
-
try {
|
|
222
|
-
const r = await callAuthJson(base, "GET", "/auth/me", {
|
|
223
|
-
token: args.access_token,
|
|
224
|
-
});
|
|
225
|
-
if (!r.ok) {
|
|
226
|
-
return toolError(String(r.detail ?? "unauthorized"), "auth_required", {
|
|
227
|
-
next_actions: ["Call user_auth_login to obtain a fresh access_token."],
|
|
228
|
-
related_tools: ["user_auth_login"],
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
return jsonOk(r);
|
|
232
|
-
}
|
|
233
|
-
catch (e) {
|
|
234
|
-
return toolError(e instanceof Error ? e.message : String(e), "backend_error");
|
|
235
|
-
}
|
|
236
|
-
});
|
|
237
|
-
server.tool("user_auth_change_password", "Change password for the signed-in user. POST /auth/change-password with JWT.", {
|
|
238
|
-
access_token: z.string(),
|
|
239
|
-
current_password: z.string(),
|
|
240
|
-
new_password: z.string().min(6),
|
|
241
|
-
}, async (args) => {
|
|
242
|
-
try {
|
|
243
|
-
const r = await callAuthJson(base, "POST", "/auth/change-password", {
|
|
244
|
-
token: args.access_token,
|
|
245
|
-
json: {
|
|
246
|
-
current_password: args.current_password,
|
|
247
|
-
new_password: args.new_password,
|
|
248
|
-
},
|
|
249
|
-
});
|
|
250
|
-
if (!r.ok) {
|
|
251
|
-
return toolError(String(r.detail ?? "change password failed"), r.status_code === 401 ? "auth_required" : "invalid_input", {
|
|
252
|
-
related_tools: ["user_auth_login", "user_auth_me"],
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
return jsonOk(r);
|
|
256
|
-
}
|
|
257
|
-
catch (e) {
|
|
258
|
-
return toolError(e instanceof Error ? e.message : String(e), "backend_error");
|
|
259
|
-
}
|
|
260
|
-
});
|
|
261
|
-
server.tool("user_auth_delete_account", "Delete the current account. DELETE /auth/me with JWT. Irreversible.", {
|
|
262
|
-
access_token: z.string().describe("JWT from user_auth_login or user_auth_verify_otp"),
|
|
263
|
-
}, async (args) => {
|
|
264
|
-
try {
|
|
265
|
-
const r = await callAuthJson(base, "DELETE", "/auth/me", {
|
|
266
|
-
token: args.access_token,
|
|
267
|
-
});
|
|
268
|
-
if (!r.ok) {
|
|
269
|
-
return toolError(String(r.detail ?? "delete failed"), "auth_required", {
|
|
270
|
-
related_tools: ["user_auth_login"],
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
return jsonOk(r);
|
|
274
|
-
}
|
|
275
|
-
catch (e) {
|
|
276
|
-
return toolError(e instanceof Error ? e.message : String(e), "backend_error");
|
|
277
|
-
}
|
|
278
|
-
});
|
|
279
|
-
}
|