@peakflo/agent-harness 0.2.6-rc.1 → 0.2.7-rc.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/dist/adapters/pi-adapter.js +5 -0
- package/dist/harness.d.ts +2 -0
- package/dist/harness.js +7 -0
- package/dist/runtime/mcp-preflight.d.ts +21 -0
- package/dist/runtime/mcp-preflight.js +126 -0
- package/dist/runtime/session-runner.d.ts +9 -0
- package/dist/runtime/session-runner.js +80 -17
- package/package.json +2 -1
|
@@ -157,6 +157,11 @@ function buildPiMcpConfigDocument(servers, onRename) {
|
|
|
157
157
|
{
|
|
158
158
|
url: server.url,
|
|
159
159
|
headers: server.headers ?? {},
|
|
160
|
+
// Pi's default lazy mode registers a namespace tool only when it
|
|
161
|
+
// already has cached tool metadata. A new session has no cache for
|
|
162
|
+
// this URL, so the model can see fewer servers than the lease gave
|
|
163
|
+
// it. Eager mode lists tools before the first model prompt.
|
|
164
|
+
lifecycle: 'eager',
|
|
160
165
|
// The managed integrations gateway can wait for a cold pfMCP
|
|
161
166
|
// instance. Its own upstream deadline is 270 seconds, so Pi must
|
|
162
167
|
// wait long enough to receive the gateway's result or clear error.
|
package/dist/harness.d.ts
CHANGED
|
@@ -135,6 +135,8 @@ export declare class Harness {
|
|
|
135
135
|
private readonly notifier;
|
|
136
136
|
private readonly artifacts;
|
|
137
137
|
private readonly tokens;
|
|
138
|
+
/** One state for all leased turns in this process; each turn gets a new runner. */
|
|
139
|
+
private readonly mcpCheckState;
|
|
138
140
|
private readonly startedAt;
|
|
139
141
|
private status;
|
|
140
142
|
private tenantId;
|
package/dist/harness.js
CHANGED
|
@@ -112,6 +112,12 @@ class Harness {
|
|
|
112
112
|
notifier;
|
|
113
113
|
artifacts;
|
|
114
114
|
tokens = new ports_default_1.SessionTokenInjector(null);
|
|
115
|
+
/** One state for all leased turns in this process; each turn gets a new runner. */
|
|
116
|
+
mcpCheckState = {
|
|
117
|
+
checked: new Map(),
|
|
118
|
+
failed: new Map(),
|
|
119
|
+
shownNotices: new Set()
|
|
120
|
+
};
|
|
115
121
|
startedAt = Date.now();
|
|
116
122
|
status = 'starting';
|
|
117
123
|
tenantId = null;
|
|
@@ -1403,6 +1409,7 @@ class Harness {
|
|
|
1403
1409
|
*/
|
|
1404
1410
|
const modelGateway = new model_gateway_1.SessionModelGateway();
|
|
1405
1411
|
const runner = new session_runner_1.SessionRunner({
|
|
1412
|
+
mcpCheckState: this.mcpCheckState,
|
|
1406
1413
|
getAdapter: (backend) => {
|
|
1407
1414
|
const build = this.deps.getAdapter;
|
|
1408
1415
|
if (!build) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface UnavailableMcpServer {
|
|
2
|
+
name: string;
|
|
3
|
+
reason: string;
|
|
4
|
+
/** A cold integrations server may still connect through Pi after this check. */
|
|
5
|
+
keepConfigured?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface FailedMcpCheck {
|
|
8
|
+
reason: string;
|
|
9
|
+
keepConfigured: boolean;
|
|
10
|
+
nextRetryAt: number;
|
|
11
|
+
backoffMs: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Check leased HTTP servers and use the caller's bounded, shared cache.
|
|
15
|
+
* A successful check is only a startup hint. The gateway checks live access
|
|
16
|
+
* on each tool call, even when a cached success skips this probe.
|
|
17
|
+
*/
|
|
18
|
+
export declare function verifyMcpServers(servers: Record<string, {
|
|
19
|
+
type: string;
|
|
20
|
+
url?: string;
|
|
21
|
+
}>, signal?: AbortSignal, successful?: Map<string, number>, failed?: Map<string, FailedMcpCheck>): Promise<UnavailableMcpServer[]>;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.verifyMcpServers = verifyMcpServers;
|
|
4
|
+
const client_1 = require("@modelcontextprotocol/client");
|
|
5
|
+
const SUCCESS_CACHE_TTL_MS = 5 * 60_000;
|
|
6
|
+
const INITIAL_FAILURE_BACKOFF_MS = 15_000;
|
|
7
|
+
const MAX_FAILURE_BACKOFF_MS = 2 * 60_000;
|
|
8
|
+
const MAX_CACHE_ENTRIES = 1024;
|
|
9
|
+
function safeHttpStatus(cause) {
|
|
10
|
+
let current = cause;
|
|
11
|
+
for (let depth = 0; depth < 4 && current instanceof Error; depth++) {
|
|
12
|
+
if (current instanceof client_1.SdkHttpError)
|
|
13
|
+
return current.status;
|
|
14
|
+
current = current.cause;
|
|
15
|
+
}
|
|
16
|
+
const match = String(cause).match(/^(?:Error:\s*)?HTTP\s+([45]\d\d)\b/i);
|
|
17
|
+
return match ? Number(match[1]) : null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Check leased HTTP servers and use the caller's bounded, shared cache.
|
|
21
|
+
* A successful check is only a startup hint. The gateway checks live access
|
|
22
|
+
* on each tool call, even when a cached success skips this probe.
|
|
23
|
+
*/
|
|
24
|
+
async function verifyMcpServers(servers, signal, successful, failed) {
|
|
25
|
+
const results = await Promise.all(Object.entries(servers).map(async ([name, server]) => {
|
|
26
|
+
if (server.type !== 'http' || !server.url)
|
|
27
|
+
return null;
|
|
28
|
+
if (signal?.aborted)
|
|
29
|
+
throw new Error('MCP check was cancelled.');
|
|
30
|
+
const key = `${name}\n${server.url}`;
|
|
31
|
+
const checkedAt = successful?.get(key);
|
|
32
|
+
if (checkedAt !== undefined &&
|
|
33
|
+
Date.now() - checkedAt < SUCCESS_CACHE_TTL_MS)
|
|
34
|
+
return null;
|
|
35
|
+
const priorFailure = failed?.get(key);
|
|
36
|
+
if (priorFailure && Date.now() < priorFailure.nextRetryAt) {
|
|
37
|
+
return {
|
|
38
|
+
name,
|
|
39
|
+
reason: priorFailure.reason,
|
|
40
|
+
...(priorFailure.keepConfigured ? { keepConfigured: true } : {})
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
let reason = 'connection or tool listing failed';
|
|
44
|
+
let status = null;
|
|
45
|
+
let timedOut = false;
|
|
46
|
+
// Each attempt has one shared connect+list deadline. A cold integrations
|
|
47
|
+
// server stays configured after a timeout; Pi has its own longer limit.
|
|
48
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
49
|
+
if (signal?.aborted)
|
|
50
|
+
throw new Error('MCP check was cancelled.');
|
|
51
|
+
const client = new client_1.Client({ name: 'peakflo-agent-harness-check', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
|
52
|
+
const deadline = AbortSignal.timeout(10_000);
|
|
53
|
+
const requestSignal = signal
|
|
54
|
+
? AbortSignal.any([signal, deadline])
|
|
55
|
+
: deadline;
|
|
56
|
+
try {
|
|
57
|
+
await client.connect(new client_1.StreamableHTTPClientTransport(new URL(server.url)), { timeout: 10_000, signal: requestSignal });
|
|
58
|
+
await client.listTools({}, { timeout: 10_000, signal: requestSignal });
|
|
59
|
+
successful?.set(key, Date.now());
|
|
60
|
+
failed?.delete(key);
|
|
61
|
+
if (successful && successful.size > MAX_CACHE_ENTRIES) {
|
|
62
|
+
successful.delete(successful.keys().next().value);
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
catch (cause) {
|
|
67
|
+
if (signal?.aborted)
|
|
68
|
+
throw new Error('MCP check was cancelled.');
|
|
69
|
+
// SDK errors can include a URL or response body. Expose only an
|
|
70
|
+
// explicit HTTP status, never the raw error.
|
|
71
|
+
status = safeHttpStatus(cause);
|
|
72
|
+
timedOut = deadline.aborted;
|
|
73
|
+
reason = timedOut
|
|
74
|
+
? 'timed out'
|
|
75
|
+
: status
|
|
76
|
+
? `HTTP ${status}`
|
|
77
|
+
: 'connection or tool listing failed';
|
|
78
|
+
if (status &&
|
|
79
|
+
status >= 400 &&
|
|
80
|
+
status < 500 &&
|
|
81
|
+
status !== 408 &&
|
|
82
|
+
status !== 429)
|
|
83
|
+
break;
|
|
84
|
+
// Do not spend another ten seconds probing a cold integrations
|
|
85
|
+
// server. Pi can still connect to it with its own 300-second limit.
|
|
86
|
+
if (name === 'integrations' && timedOut)
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
await client.close().catch(() => undefined);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const keepConfigured = name === 'integrations' &&
|
|
94
|
+
(timedOut ||
|
|
95
|
+
status === 408 ||
|
|
96
|
+
status === 429 ||
|
|
97
|
+
(status !== null && status >= 500));
|
|
98
|
+
const backoffMs = Math.min(priorFailure ? priorFailure.backoffMs * 2 : INITIAL_FAILURE_BACKOFF_MS, MAX_FAILURE_BACKOFF_MS);
|
|
99
|
+
// Access can change between turns. Recheck 4xx refusals immediately;
|
|
100
|
+
// only temporary failures use backoff.
|
|
101
|
+
if (timedOut ||
|
|
102
|
+
status === null ||
|
|
103
|
+
status === 408 ||
|
|
104
|
+
status === 429 ||
|
|
105
|
+
status >= 500) {
|
|
106
|
+
failed?.set(key, {
|
|
107
|
+
reason,
|
|
108
|
+
keepConfigured,
|
|
109
|
+
nextRetryAt: Date.now() + backoffMs,
|
|
110
|
+
backoffMs
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
failed?.delete(key);
|
|
115
|
+
}
|
|
116
|
+
if (failed && failed.size > MAX_CACHE_ENTRIES) {
|
|
117
|
+
failed.delete(failed.keys().next().value);
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
name,
|
|
121
|
+
reason,
|
|
122
|
+
...(keepConfigured ? { keepConfigured: true } : {})
|
|
123
|
+
};
|
|
124
|
+
}));
|
|
125
|
+
return results.filter((result) => result !== null);
|
|
126
|
+
}
|
|
@@ -24,6 +24,7 @@ import { ArtifactInspector, ConversationStore, EventSink, ModelMessageRecord, No
|
|
|
24
24
|
import { LeaseResponse, LeasedTurnInput, ModelMessageInput, TranscriptPartInput, TurnMetadata } from '../types';
|
|
25
25
|
import { KnowledgeToolHost, type KnowledgeControl } from './knowledge-tools';
|
|
26
26
|
import { McpAuthProxy } from './mcp-auth-proxy';
|
|
27
|
+
import { type FailedMcpCheck } from './mcp-preflight';
|
|
27
28
|
export interface SessionRunnerDeps {
|
|
28
29
|
/** Resolves a backend name from the lease to a working adapter. */
|
|
29
30
|
getAdapter(backend: string): CodingAgentAdapter;
|
|
@@ -34,6 +35,8 @@ export interface SessionRunnerDeps {
|
|
|
34
35
|
* consults a gateway, keep constructing a runner without one.
|
|
35
36
|
*/
|
|
36
37
|
modelGateway?: SessionModelGateway;
|
|
38
|
+
/** Shared by the harness across disposable runners for leased turns. */
|
|
39
|
+
mcpCheckState?: McpCheckState;
|
|
37
40
|
store: TranscriptStore;
|
|
38
41
|
workspace: WorkspaceProvider;
|
|
39
42
|
proxy: McpAuthProxy;
|
|
@@ -248,9 +251,15 @@ export declare function decodeQuestionAnswers(answer: string | undefined): Recor
|
|
|
248
251
|
* Codex's "Allow" read as a denial.
|
|
249
252
|
*/
|
|
250
253
|
export declare function isApprovingOptionId(optionId: string): boolean;
|
|
254
|
+
export interface McpCheckState {
|
|
255
|
+
checked: Map<string, number>;
|
|
256
|
+
failed: Map<string, FailedMcpCheck>;
|
|
257
|
+
shownNotices: Set<string>;
|
|
258
|
+
}
|
|
251
259
|
export declare class SessionRunner {
|
|
252
260
|
private readonly deps;
|
|
253
261
|
private readonly logger;
|
|
262
|
+
private readonly mcpCheckState;
|
|
254
263
|
/**
|
|
255
264
|
* What the running turn needs in order to ANSWER a permission from outside
|
|
256
265
|
* the turn's own call stack.
|
|
@@ -34,6 +34,7 @@ const attachments_1 = require("./attachments");
|
|
|
34
34
|
const cold_start_steps_1 = require("./cold-start-steps");
|
|
35
35
|
const conversation_1 = require("./conversation");
|
|
36
36
|
const knowledge_tools_1 = require("./knowledge-tools");
|
|
37
|
+
const mcp_preflight_1 = require("./mcp-preflight");
|
|
37
38
|
const permission_mode_1 = require("./permission-mode");
|
|
38
39
|
const projection_1 = require("./projection");
|
|
39
40
|
const space_files_1 = require("./space-files");
|
|
@@ -375,6 +376,7 @@ function resolveApproval(command, open) {
|
|
|
375
376
|
class SessionRunner {
|
|
376
377
|
deps;
|
|
377
378
|
logger;
|
|
379
|
+
mcpCheckState;
|
|
378
380
|
/**
|
|
379
381
|
* What the running turn needs in order to ANSWER a permission from outside
|
|
380
382
|
* the turn's own call stack.
|
|
@@ -565,6 +567,11 @@ class SessionRunner {
|
|
|
565
567
|
constructor(deps) {
|
|
566
568
|
this.deps = deps;
|
|
567
569
|
this.logger = deps.logger ?? console;
|
|
570
|
+
this.mcpCheckState = deps.mcpCheckState ?? {
|
|
571
|
+
checked: new Map(),
|
|
572
|
+
failed: new Map(),
|
|
573
|
+
shownNotices: new Set()
|
|
574
|
+
};
|
|
568
575
|
}
|
|
569
576
|
/**
|
|
570
577
|
* True while a permission prompt is outstanding. Read by the harness to
|
|
@@ -615,6 +622,7 @@ class SessionRunner {
|
|
|
615
622
|
* files exist.
|
|
616
623
|
*/
|
|
617
624
|
let turnContent = session.input.content;
|
|
625
|
+
let turnMcpNotice = null;
|
|
618
626
|
this.turnParts = [];
|
|
619
627
|
this.steerTurnGeneration++;
|
|
620
628
|
this.steerAcknowledgementsClosed = false;
|
|
@@ -845,8 +853,46 @@ class SessionRunner {
|
|
|
845
853
|
});
|
|
846
854
|
knowledgeUrl = await this.deps.knowledge.host.endpointFor(session.sessionId);
|
|
847
855
|
}
|
|
848
|
-
const
|
|
856
|
+
const unavailableMcpServers = [];
|
|
857
|
+
const config = this.buildSessionConfig(session, workspaceDir, secretToken, signal, knowledgeUrl, unavailableMcpServers);
|
|
849
858
|
config.coldStartLog = this.deps.coldStartLog;
|
|
859
|
+
const endMcpPreflight = coldStart.begin('mcp_preflight');
|
|
860
|
+
try {
|
|
861
|
+
unavailableMcpServers.push(...(await (0, mcp_preflight_1.verifyMcpServers)(config.mcpServers ?? {}, signal, this.mcpCheckState.checked, this.mcpCheckState.failed)));
|
|
862
|
+
}
|
|
863
|
+
finally {
|
|
864
|
+
endMcpPreflight();
|
|
865
|
+
}
|
|
866
|
+
for (const unavailable of unavailableMcpServers) {
|
|
867
|
+
if (!unavailable.keepConfigured) {
|
|
868
|
+
delete config.mcpServers?.[unavailable.name];
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
const modelNotices = unavailableMcpServers.map(({ name, reason, keepConfigured }) => keepConfigured
|
|
872
|
+
? `MCP server "${name}" did not answer the check (${reason}). Its tools may become available later in this turn.`
|
|
873
|
+
: `MCP server "${name}" is unavailable (${reason}). Its tools cannot be used in this turn.`);
|
|
874
|
+
turnMcpNotice = modelNotices.length > 0 ? modelNotices.join('\n') : null;
|
|
875
|
+
const userNotices = unavailableMcpServers.flatMap(({ name }, index) => {
|
|
876
|
+
const key = `${session.sessionId}\n${name}`;
|
|
877
|
+
if (this.mcpCheckState.shownNotices.has(key))
|
|
878
|
+
return [];
|
|
879
|
+
this.mcpCheckState.shownNotices.add(key);
|
|
880
|
+
if (this.mcpCheckState.shownNotices.size > 1024) {
|
|
881
|
+
this.mcpCheckState.shownNotices.delete(this.mcpCheckState.shownNotices.values().next().value);
|
|
882
|
+
}
|
|
883
|
+
return [modelNotices[index]];
|
|
884
|
+
});
|
|
885
|
+
if (userNotices.length > 0) {
|
|
886
|
+
partsProduced += this.record(session.sessionId, [
|
|
887
|
+
{
|
|
888
|
+
id: `mcp-unavailable-${session.turnId}`,
|
|
889
|
+
role: 'system',
|
|
890
|
+
partType: 'text',
|
|
891
|
+
content: userNotices.join('\n')
|
|
892
|
+
}
|
|
893
|
+
]);
|
|
894
|
+
await this.flush(session.sessionId, this.pending.splice(0), undefined, false);
|
|
895
|
+
}
|
|
850
896
|
/*
|
|
851
897
|
* REMEMBER THE CONVERSATION. This block is the fix for the defect that
|
|
852
898
|
* made this a chat product that could not hold a conversation: the
|
|
@@ -1002,11 +1048,29 @@ class SessionRunner {
|
|
|
1002
1048
|
const promptWithHostNote = turnNote
|
|
1003
1049
|
? `${turnNote}\n\n${promptText}`
|
|
1004
1050
|
: promptText;
|
|
1051
|
+
const promptParts = [
|
|
1052
|
+
...(turnMcpNotice
|
|
1053
|
+
? [
|
|
1054
|
+
{
|
|
1055
|
+
type: 'text',
|
|
1056
|
+
text: `Tool access notice:\n${turnMcpNotice}`,
|
|
1057
|
+
role: 'system'
|
|
1058
|
+
}
|
|
1059
|
+
]
|
|
1060
|
+
: []),
|
|
1061
|
+
{ type: 'text', text: promptWithHostNote, role: 'user' }
|
|
1062
|
+
];
|
|
1063
|
+
// Pi, ACP, Claude Code, and Codex join parts with a newline. OpenCode
|
|
1064
|
+
// keeps them separate. Accept echoes in either representation.
|
|
1065
|
+
const sentPromptParts = promptParts
|
|
1066
|
+
.filter((part) => part.type === 'text' && Boolean(part.text))
|
|
1067
|
+
.map((part) => part.text);
|
|
1068
|
+
const sentPromptTexts = [sentPromptParts.join('\n'), ...sentPromptParts];
|
|
1005
1069
|
replayed = !resumed && memory.messages.length > 0;
|
|
1006
1070
|
priorPartIdsForLog = priorPartIds;
|
|
1007
1071
|
const endPrompt = coldStart.begin('model_prompt_send');
|
|
1008
1072
|
try {
|
|
1009
|
-
await adapter.sendPrompt(backendSessionId,
|
|
1073
|
+
await adapter.sendPrompt(backendSessionId, promptParts, config);
|
|
1010
1074
|
}
|
|
1011
1075
|
finally {
|
|
1012
1076
|
endPrompt();
|
|
@@ -1044,7 +1108,7 @@ class SessionRunner {
|
|
|
1044
1108
|
resumed ? priorPartIds : new Set(),
|
|
1045
1109
|
// THE PROMPT THIS RUNNER JUST SENT. Drain uses it to recognise the
|
|
1046
1110
|
// backend's own echo of it — see `drain`.
|
|
1047
|
-
|
|
1111
|
+
sentPromptTexts, () => endFirstOutput(), () => endFirstIngest());
|
|
1048
1112
|
partsProduced = drained.parts;
|
|
1049
1113
|
if (signal.aborted) {
|
|
1050
1114
|
stopReason = 'aborted';
|
|
@@ -1300,7 +1364,7 @@ class SessionRunner {
|
|
|
1300
1364
|
* Both sides are trimmed first: a backend that strips a trailing newline
|
|
1301
1365
|
* from the echoed text must not slip past the prefix check on that alone.
|
|
1302
1366
|
*/
|
|
1303
|
-
isEchoOfTurnPrompt(part,
|
|
1367
|
+
isEchoOfTurnPrompt(part, sentPromptTexts) {
|
|
1304
1368
|
if (typeof part.role !== 'string' || part.role.toLowerCase() !== 'user') {
|
|
1305
1369
|
return false;
|
|
1306
1370
|
}
|
|
@@ -1310,7 +1374,7 @@ class SessionRunner {
|
|
|
1310
1374
|
const echoed = text.trim();
|
|
1311
1375
|
if (echoed.length === 0)
|
|
1312
1376
|
return false;
|
|
1313
|
-
return
|
|
1377
|
+
return sentPromptTexts.some((text) => text.trim().startsWith(echoed));
|
|
1314
1378
|
}
|
|
1315
1379
|
isExactSteerEcho(part, steeredPrompt) {
|
|
1316
1380
|
if (typeof part.role !== 'string' || part.role.toLowerCase() !== 'user')
|
|
@@ -1345,7 +1409,7 @@ class SessionRunner {
|
|
|
1345
1409
|
*/
|
|
1346
1410
|
async drain(adapter, backendSessionId, config, session, signal, alreadySeenPartIds = new Set(),
|
|
1347
1411
|
/** The exact text `sendPrompt` was given this turn, for echo filtering. */
|
|
1348
|
-
|
|
1412
|
+
sentPromptTexts = null, onFirstOutput, onFirstIngest) {
|
|
1349
1413
|
let providerError;
|
|
1350
1414
|
let timedOut = false;
|
|
1351
1415
|
let idleTimedOut = false;
|
|
@@ -1468,9 +1532,9 @@ class SessionRunner {
|
|
|
1468
1532
|
* be. Answers to questions travel a different wire (`respondTo*`) and
|
|
1469
1533
|
* are recorded by the answer path itself, with the `answer-…` part id.
|
|
1470
1534
|
*/
|
|
1471
|
-
const freshParts =
|
|
1535
|
+
const freshParts = sentPromptTexts && sentPromptTexts.length > 0
|
|
1472
1536
|
? parts.filter((part) => {
|
|
1473
|
-
if (this.isEchoOfTurnPrompt(part,
|
|
1537
|
+
if (this.isEchoOfTurnPrompt(part, sentPromptTexts))
|
|
1474
1538
|
return false;
|
|
1475
1539
|
return !this.consumesSteerEcho(part);
|
|
1476
1540
|
})
|
|
@@ -2357,23 +2421,22 @@ class SessionRunner {
|
|
|
2357
2421
|
* security perimeter and trusting it to filter its own tools is exactly
|
|
2358
2422
|
* defect G2.
|
|
2359
2423
|
*/
|
|
2360
|
-
buildSessionConfig(session, workspaceDir, secretToken, signal, knowledgeUrl = null) {
|
|
2424
|
+
buildSessionConfig(session, workspaceDir, secretToken, signal, knowledgeUrl = null, unavailable = []) {
|
|
2361
2425
|
const mcpServers = {};
|
|
2362
2426
|
for (const server of session.agent.mcpServers) {
|
|
2363
2427
|
const proxied = this.deps.proxy.registerTarget(server.url, server.name);
|
|
2428
|
+
if (!proxied) {
|
|
2429
|
+
unavailable.push({
|
|
2430
|
+
name: server.name,
|
|
2431
|
+
reason: 'local MCP proxy is unavailable'
|
|
2432
|
+
});
|
|
2433
|
+
continue;
|
|
2434
|
+
}
|
|
2364
2435
|
mcpServers[server.name] = {
|
|
2365
2436
|
name: server.name,
|
|
2366
2437
|
type: 'http',
|
|
2367
|
-
// A null proxy URL means the proxy is not running. Falling back to the
|
|
2368
|
-
// direct URL would put the session token in the backend's config, so
|
|
2369
|
-
// the server is simply omitted instead — fewer tools is recoverable,
|
|
2370
|
-
// a leaked token is not.
|
|
2371
2438
|
url: proxied
|
|
2372
2439
|
};
|
|
2373
|
-
if (!proxied) {
|
|
2374
|
-
delete mcpServers[server.name];
|
|
2375
|
-
this.logger.warn(`[SessionRunner] MCP server "${server.name}" omitted: proxy not running`);
|
|
2376
|
-
}
|
|
2377
2440
|
}
|
|
2378
2441
|
/*
|
|
2379
2442
|
* The knowledge server is on loopback with a per-session path secret, so it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peakflo/agent-harness",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7-rc.1",
|
|
4
4
|
"description": "Headless Peakflo coding-agent harness. One codebase, three hosting modes: a Daytona cloud sandbox, a customer VM, and the 20x desktop.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"undici": "^7.21.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
+
"@modelcontextprotocol/client": "2.0.0",
|
|
40
41
|
"@peculiar/x509": "^2.1.0",
|
|
41
42
|
"reflect-metadata": "^0.2.2"
|
|
42
43
|
},
|