@pyxmate/memory 1.26.6 → 1.27.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/chunk-4DXEQC6X.mjs +16 -0
- package/dist/chunk-7IGU3HJO.mjs +70 -0
- package/dist/chunk-7TATCSSN.mjs +650 -0
- package/dist/chunk-QR5K7ETI.mjs +793 -0
- package/dist/{chunk-KZKGRND2.mjs → chunk-RWQTBXUC.mjs} +12 -0
- package/dist/chunk-S3CX323Z.mjs +34 -0
- package/dist/chunk-THLUBOGD.mjs +263 -0
- package/dist/chunk-UESIQ47D.mjs +165 -0
- package/dist/chunk-YBPODKB5.mjs +132 -0
- package/dist/cli/pyx-mem.mjs +68 -4208
- package/dist/dashboard.mjs +1 -1
- package/dist/doctor-AERNUHEZ.mjs +143 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.mjs +7 -1
- package/dist/insights-PD7Y6QDX.mjs +139 -0
- package/dist/login-4ALCAN4M.mjs +179 -0
- package/dist/login-browser-DO2O2Q2G.mjs +407 -0
- package/dist/login-recovery-AOO76PYX.mjs +97 -0
- package/dist/logout-4CO53LNA.mjs +13 -0
- package/dist/mcp-64KVRH7F.mjs +944 -0
- package/dist/mcp-install-KBB45OB2.mjs +36 -0
- package/dist/overview-COBXHLP3.mjs +88 -0
- package/dist/react.mjs +1 -1
- package/dist/scaffold-2FSCWF22.mjs +186 -0
- package/dist/status-UHJZLEPL.mjs +74 -0
- package/dist/taxonomy-EKDFDFXW.mjs +103 -0
- package/package.json +1 -1
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHttpClient,
|
|
3
|
+
mcpJson,
|
|
4
|
+
mcpKeychainError,
|
|
5
|
+
mcpMissingCredentials,
|
|
6
|
+
mcpText,
|
|
7
|
+
parseRetryAfterMs,
|
|
8
|
+
parseRetryAfterSeconds
|
|
9
|
+
} from "./chunk-THLUBOGD.mjs";
|
|
10
|
+
import {
|
|
11
|
+
KeychainError,
|
|
12
|
+
getDefaultKeychain
|
|
13
|
+
} from "./chunk-7TATCSSN.mjs";
|
|
14
|
+
import "./chunk-6EEEOV6B.mjs";
|
|
15
|
+
import {
|
|
16
|
+
MCP_ACTOR_META_KEY,
|
|
17
|
+
normalizeMemoryActorLabel
|
|
18
|
+
} from "./chunk-RWQTBXUC.mjs";
|
|
19
|
+
import {
|
|
20
|
+
HOSTED_FILE_INGEST_PROTOCOL,
|
|
21
|
+
HOSTED_FILE_INGEST_RETRYABLE_CODES,
|
|
22
|
+
parseHostedFileIngestAuthorization,
|
|
23
|
+
parseHostedFileIngestCommitResult,
|
|
24
|
+
parseHostedFileIngestJobView,
|
|
25
|
+
parseHostedFileIngestPublishResult,
|
|
26
|
+
requireHostedFileIngestFilename
|
|
27
|
+
} from "./chunk-HTQBKJTD.mjs";
|
|
28
|
+
import {
|
|
29
|
+
syncMemoryRulesFromServer
|
|
30
|
+
} from "./chunk-QR5K7ETI.mjs";
|
|
31
|
+
import "./chunk-QD7G23CN.mjs";
|
|
32
|
+
import {
|
|
33
|
+
EXIT
|
|
34
|
+
} from "./chunk-4DXEQC6X.mjs";
|
|
35
|
+
|
|
36
|
+
// src/mcp/credentials.ts
|
|
37
|
+
function createReadCredentials(providerFactory) {
|
|
38
|
+
return async () => {
|
|
39
|
+
const provider = providerFactory();
|
|
40
|
+
try {
|
|
41
|
+
const creds = await provider.read();
|
|
42
|
+
if (!creds) return { ok: false, result: mcpMissingCredentials() };
|
|
43
|
+
return { ok: true, credentials: creds };
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err instanceof KeychainError) {
|
|
46
|
+
return { ok: false, result: mcpKeychainError(err.message, err.guidance) };
|
|
47
|
+
}
|
|
48
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
49
|
+
return { ok: false, result: mcpKeychainError(msg, "") };
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/mcp/proxy-server.ts
|
|
55
|
+
import { createHash as createHash2, randomUUID as randomUUID3 } from "crypto";
|
|
56
|
+
import { readFile as readFile2, stat as stat2 } from "fs/promises";
|
|
57
|
+
import { basename as basename2, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
58
|
+
import { setTimeout as delay2 } from "timers/promises";
|
|
59
|
+
import { Client as McpClient } from "@modelcontextprotocol/sdk/client/index.js";
|
|
60
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
61
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
62
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
63
|
+
import {
|
|
64
|
+
CallToolRequestSchema,
|
|
65
|
+
GetPromptRequestSchema,
|
|
66
|
+
ListPromptsRequestSchema,
|
|
67
|
+
ListToolsRequestSchema
|
|
68
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
69
|
+
|
|
70
|
+
// src/mcp/file-ingest-intents.ts
|
|
71
|
+
import { randomUUID } from "crypto";
|
|
72
|
+
import { mkdir, readdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
73
|
+
import { homedir } from "os";
|
|
74
|
+
import { join } from "path";
|
|
75
|
+
var DELIVERED_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
76
|
+
var DEFAULT_FILE_INGEST_INTENT_DIR = join(homedir(), ".pyx-memory", "file-ingests");
|
|
77
|
+
function parseIntent(raw, file) {
|
|
78
|
+
const value = JSON.parse(raw);
|
|
79
|
+
const fp = value?.fingerprint;
|
|
80
|
+
if (value?.version !== 1 || typeof value.jobId !== "string" || typeof value.endpoint !== "string" || typeof value.delivered !== "boolean" || typeof value.createdAt !== "string" || typeof value.updatedAt !== "string" || typeof fp?.path !== "string" || typeof fp.byteLength !== "number" || typeof fp.sha256 !== "string") {
|
|
81
|
+
throw new Error(`file ingest intent ${file} is not a version 1 record; remove it to continue`);
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
async function readIntents(dir) {
|
|
86
|
+
let names;
|
|
87
|
+
try {
|
|
88
|
+
names = await readdir(dir);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err.code === "ENOENT") return [];
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
return Promise.all(
|
|
94
|
+
names.filter((name) => name.endsWith(".json")).map(async (name) => {
|
|
95
|
+
const file = join(dir, name);
|
|
96
|
+
return { file, intent: parseIntent(await readFile(file, "utf8"), file) };
|
|
97
|
+
})
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
function createFileIngestIntentStore(dir = DEFAULT_FILE_INGEST_INTENT_DIR) {
|
|
101
|
+
return {
|
|
102
|
+
async findUndelivered(endpoint, path, namespaceId) {
|
|
103
|
+
const records = await readIntents(dir);
|
|
104
|
+
const prunedBefore = Date.now() - DELIVERED_RETENTION_MS;
|
|
105
|
+
await Promise.all(
|
|
106
|
+
records.filter(({ intent }) => intent.delivered && Date.parse(intent.updatedAt) < prunedBefore).map(({ file }) => rm(file, { force: true }))
|
|
107
|
+
);
|
|
108
|
+
return records.map(({ intent }) => intent).filter(
|
|
109
|
+
(intent) => !intent.delivered && intent.endpoint === endpoint && intent.fingerprint.path === path && intent.fingerprint.namespaceId === namespaceId
|
|
110
|
+
).sort((a, b) => a.createdAt.localeCompare(b.createdAt))[0];
|
|
111
|
+
},
|
|
112
|
+
async save(intent) {
|
|
113
|
+
await mkdir(dir, { recursive: true, mode: 448 });
|
|
114
|
+
const file = join(dir, `${intent.jobId}.json`);
|
|
115
|
+
const temp = join(dir, `.${intent.jobId}.${randomUUID()}.tmp`);
|
|
116
|
+
await writeFile(temp, `${JSON.stringify(intent)}
|
|
117
|
+
`, { mode: 384, flag: "wx" });
|
|
118
|
+
await rename(temp, file);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/mcp/hosted-file-ingest.ts
|
|
124
|
+
import { createHash, randomUUID as randomUUID2 } from "crypto";
|
|
125
|
+
import { createReadStream, openAsBlob } from "fs";
|
|
126
|
+
import { stat } from "fs/promises";
|
|
127
|
+
import { basename, isAbsolute, resolve } from "path";
|
|
128
|
+
import { setTimeout as delay } from "timers/promises";
|
|
129
|
+
function parseHostedFileIngestDescriptor(meta) {
|
|
130
|
+
if (meta === null || typeof meta !== "object") return void 0;
|
|
131
|
+
const value = meta[HOSTED_FILE_INGEST_PROTOCOL.metaKey];
|
|
132
|
+
if (value === null || typeof value !== "object") return void 0;
|
|
133
|
+
const { protocolVersion, endpoint, maxBytes } = value;
|
|
134
|
+
if (protocolVersion !== HOSTED_FILE_INGEST_PROTOCOL.version || typeof endpoint !== "string" || // Same-origin absolute path only: the keychain bearer goes with every request.
|
|
135
|
+
!endpoint.startsWith("/") || endpoint.startsWith("//") || typeof maxBytes !== "number" || !Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
|
136
|
+
return void 0;
|
|
137
|
+
}
|
|
138
|
+
return { endpoint, maxBytes };
|
|
139
|
+
}
|
|
140
|
+
function toMicrocents(cents) {
|
|
141
|
+
const microcents = typeof cents === "number" && cents >= 0 ? Math.round(cents * 1e6) : Number.NaN;
|
|
142
|
+
return Number.isSafeInteger(microcents) ? String(microcents) : void 0;
|
|
143
|
+
}
|
|
144
|
+
function parseArgs(args) {
|
|
145
|
+
const { path, description, namespaceId, maxCostCents, cancel } = args;
|
|
146
|
+
const maxCostMicrocents = maxCostCents === void 0 ? void 0 : toMicrocents(maxCostCents);
|
|
147
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
148
|
+
return "`path` must be a non-empty local file path.";
|
|
149
|
+
}
|
|
150
|
+
if (description !== void 0 && typeof description !== "string") {
|
|
151
|
+
return "`description` must be a string.";
|
|
152
|
+
}
|
|
153
|
+
if (namespaceId !== void 0 && (typeof namespaceId !== "string" || namespaceId.length === 0)) {
|
|
154
|
+
return "`namespaceId` must be a non-empty string.";
|
|
155
|
+
}
|
|
156
|
+
if (cancel !== void 0 && typeof cancel !== "boolean") return "`cancel` must be a boolean.";
|
|
157
|
+
if (maxCostCents !== void 0 && maxCostMicrocents === void 0) {
|
|
158
|
+
return "`maxCostCents` must be a non-negative number of cents.";
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
path: isAbsolute(path) ? path : resolve(path),
|
|
162
|
+
description: description || null,
|
|
163
|
+
namespaceId: namespaceId ?? null,
|
|
164
|
+
maxCostMicrocents,
|
|
165
|
+
cancel: cancel === true
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
var CATALOG_STEPS = /* @__PURE__ */ new Set(["begin", "prepare", "commit", "publish"]);
|
|
169
|
+
var DEADLINE_MS = {
|
|
170
|
+
begin: 15e3,
|
|
171
|
+
authorize: 15e3,
|
|
172
|
+
cancel: 15e3,
|
|
173
|
+
upload: 12e4,
|
|
174
|
+
prepare: 9e4,
|
|
175
|
+
commit: 16e4,
|
|
176
|
+
publish: 6e4
|
|
177
|
+
};
|
|
178
|
+
var RETRY_BUDGET_MS = 5 * 6e4;
|
|
179
|
+
var DEFAULT_RETRY_AFTER_S = 2;
|
|
180
|
+
var MAX_RETRY_AFTER_S = 30;
|
|
181
|
+
var RESUME = "Call this tool again with the same arguments to resume the job.";
|
|
182
|
+
var NEW_IMPORT = "Call this tool again to start a new import.";
|
|
183
|
+
var NEXT_BY_CODE = /* @__PURE__ */ new Map([
|
|
184
|
+
[
|
|
185
|
+
"runtime_upgrade_required",
|
|
186
|
+
"The pyx-memory instance needs a runtime upgrade before file ingest can continue; call this tool again with the same arguments once it is upgraded."
|
|
187
|
+
],
|
|
188
|
+
[
|
|
189
|
+
"file_ingest_job_limit",
|
|
190
|
+
"This instance already has as many unfinished imports as it allows: finish or cancel another one (cancel: true with its path), then call this tool again with the same arguments."
|
|
191
|
+
],
|
|
192
|
+
[
|
|
193
|
+
"file_ingest_disabled",
|
|
194
|
+
"Hosted file ingest is switched off on the server; call this tool again with the same arguments later to resume."
|
|
195
|
+
],
|
|
196
|
+
[
|
|
197
|
+
"file_too_large",
|
|
198
|
+
"The file is larger than hosted file ingest accepts: call with cancel: true to drop this import, then import a smaller file."
|
|
199
|
+
],
|
|
200
|
+
[
|
|
201
|
+
"file_ingest_cancel_requested",
|
|
202
|
+
"The job is being cancelled: call this tool with cancel: true to confirm."
|
|
203
|
+
]
|
|
204
|
+
]);
|
|
205
|
+
var ENDED_CODES = /* @__PURE__ */ new Set(["file_ingest_cancelled", "file_ingest_expired"]);
|
|
206
|
+
var COST_CODES = /* @__PURE__ */ new Set([
|
|
207
|
+
"file_ingest_authorization_required",
|
|
208
|
+
"file_ingest_ceiling_exceeded",
|
|
209
|
+
"file_ingest_quota_paused",
|
|
210
|
+
"file_ingest_limit_exceeded"
|
|
211
|
+
]);
|
|
212
|
+
function nextAfter(res) {
|
|
213
|
+
if (res.status === 402) {
|
|
214
|
+
return "The wallet balance is too low: top up, then call this tool again with the same arguments to resume.";
|
|
215
|
+
}
|
|
216
|
+
return NEXT_BY_CODE.get(res.code ?? "") ?? RESUME;
|
|
217
|
+
}
|
|
218
|
+
var errorText = (err) => err instanceof Error ? err.message : String(err);
|
|
219
|
+
var text = (result) => result.content.map((part) => part.text).join("\n");
|
|
220
|
+
var fail = (result) => ({ ok: false, result });
|
|
221
|
+
var refuse = (message) => fail(mcpText(message, true));
|
|
222
|
+
var label = (intent) => `${basename(intent.fingerprint.path)} (job ${intent.jobId})`;
|
|
223
|
+
async function hashFile(path) {
|
|
224
|
+
const hash = createHash("sha256");
|
|
225
|
+
let byteLength = 0;
|
|
226
|
+
for await (const chunk of createReadStream(path)) {
|
|
227
|
+
hash.update(chunk);
|
|
228
|
+
byteLength += chunk.length;
|
|
229
|
+
}
|
|
230
|
+
return { byteLength, sha256: hash.digest("hex") };
|
|
231
|
+
}
|
|
232
|
+
async function matchIntent(intent, args, size) {
|
|
233
|
+
const fp = intent.fingerprint;
|
|
234
|
+
const changed = args.description !== fp.description || typeof size === "number" && (size !== fp.byteLength || (await hashFile(args.path)).sha256 !== fp.sha256);
|
|
235
|
+
if (!changed) return { ok: true, value: intent };
|
|
236
|
+
return refuse(
|
|
237
|
+
`${args.path} no longer matches ${label(intent)}: its content or description changed since that import started.
|
|
238
|
+
Call this tool with cancel: true to abandon job ${intent.jobId}, then call again to import the file as it is now.`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
function isRetryable(res) {
|
|
242
|
+
return res.status === 429 || res.status === 503 || HOSTED_FILE_INGEST_RETRYABLE_CODES.includes(res.code ?? "");
|
|
243
|
+
}
|
|
244
|
+
async function pause(ms, signal) {
|
|
245
|
+
await delay(ms, void 0, { signal }).catch((err) => {
|
|
246
|
+
if (!signal?.aborted) throw err;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
function retryAfterMs(header) {
|
|
250
|
+
const seconds = parseRetryAfterSeconds(header) ?? DEFAULT_RETRY_AFTER_S;
|
|
251
|
+
return Math.min(seconds, MAX_RETRY_AFTER_S) * 1e3;
|
|
252
|
+
}
|
|
253
|
+
function formatCents(microcents, decimals) {
|
|
254
|
+
const step = 10n ** BigInt(6 - decimals);
|
|
255
|
+
const units = (BigInt(microcents) + step - 1n) / step;
|
|
256
|
+
const scale = 10n ** BigInt(decimals);
|
|
257
|
+
return `${units / scale}.${String(units % scale).padStart(decimals, "0")}`;
|
|
258
|
+
}
|
|
259
|
+
function unwrapEnvelope(body) {
|
|
260
|
+
if (body === null || typeof body !== "object" || body.success !== true) {
|
|
261
|
+
throw new Error("the response is not a {success: true, data} envelope");
|
|
262
|
+
}
|
|
263
|
+
return body.data;
|
|
264
|
+
}
|
|
265
|
+
function createHostedFileIngestDriver(credentials, guard, intents, fetchImpl = fetch) {
|
|
266
|
+
const http = createHttpClient(credentials, fetchImpl);
|
|
267
|
+
const origin = new URL(credentials.endpoint).origin;
|
|
268
|
+
const stopped = (call, step, detail, next = RESUME) => mcpText(`Import of ${label(call.intent)} stopped at ${step}: ${detail}
|
|
269
|
+
${next}`, true);
|
|
270
|
+
const interrupted = (call) => refuse(`The call was cancelled; ${label(call.intent)} keeps its progress.
|
|
271
|
+
${RESUME}`);
|
|
272
|
+
const published = (call, publication) => mcpText(
|
|
273
|
+
`Ingested ${call.filename}: ${publication.entryCount} chunks stored (replaced ${publication.retiredCount}). job ${call.intent.jobId}`
|
|
274
|
+
);
|
|
275
|
+
async function record(call, state) {
|
|
276
|
+
if (call.intent.lastState === state) return;
|
|
277
|
+
call.intent.lastState = state;
|
|
278
|
+
call.intent.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
279
|
+
await intents.save(call.intent);
|
|
280
|
+
}
|
|
281
|
+
async function deliver(call, result) {
|
|
282
|
+
call.intent.delivered = true;
|
|
283
|
+
call.intent.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
284
|
+
await intents.save(call.intent);
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
function accept(call, step, body, parse) {
|
|
288
|
+
try {
|
|
289
|
+
return { ok: true, value: parse(unwrapEnvelope(body)) };
|
|
290
|
+
} catch (err) {
|
|
291
|
+
return fail(stopped(call, step, `invalid server response: ${errorText(err)}`));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async function exchange(call, step, attempt, parse) {
|
|
295
|
+
for (; ; ) {
|
|
296
|
+
await guard();
|
|
297
|
+
const res = await attempt(randomUUID2());
|
|
298
|
+
if (call.signal?.aborted) return interrupted(call);
|
|
299
|
+
if (res.ok) return accept(call, step, res.data, parse);
|
|
300
|
+
const waitMs = retryAfterMs(res.retryAfter);
|
|
301
|
+
if (!isRetryable(res) || Date.now() + waitMs > call.retryUntil)
|
|
302
|
+
return refused(call, step, res);
|
|
303
|
+
await pause(waitMs, call.signal);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function post(call, step, route, body, parse) {
|
|
307
|
+
const { namespaceId } = call.intent.fingerprint;
|
|
308
|
+
const scoped = CATALOG_STEPS.has(step) && namespaceId !== null;
|
|
309
|
+
return exchange(
|
|
310
|
+
call,
|
|
311
|
+
step,
|
|
312
|
+
(idempotencyKey) => http.requestJson({
|
|
313
|
+
method: "POST",
|
|
314
|
+
path: `${call.descriptor.endpoint}${route}`,
|
|
315
|
+
body: scoped ? { ...body, namespaceId } : body,
|
|
316
|
+
idempotencyKey,
|
|
317
|
+
timeoutMs: DEADLINE_MS[step],
|
|
318
|
+
signal: call.signal
|
|
319
|
+
}),
|
|
320
|
+
parse
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
async function upload(call, job) {
|
|
324
|
+
const token = job.upload?.token;
|
|
325
|
+
if (token === void 0) {
|
|
326
|
+
return fail(stopped(call, "upload", "the server issued no upload token"));
|
|
327
|
+
}
|
|
328
|
+
const { path, byteLength, sha256 } = call.intent.fingerprint;
|
|
329
|
+
let body;
|
|
330
|
+
try {
|
|
331
|
+
const current = await hashFile(path);
|
|
332
|
+
body = await openAsBlob(path);
|
|
333
|
+
if (current.sha256 !== sha256 || current.byteLength !== byteLength || body.size !== byteLength) {
|
|
334
|
+
throw new Error("it changed since the import started");
|
|
335
|
+
}
|
|
336
|
+
} catch (err) {
|
|
337
|
+
return fail(
|
|
338
|
+
stopped(
|
|
339
|
+
call,
|
|
340
|
+
"upload",
|
|
341
|
+
`cannot send ${path}: ${errorText(err)}`,
|
|
342
|
+
`Restore the file and call again to resume, or call with cancel: true to abandon job ${call.intent.jobId}.`
|
|
343
|
+
)
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return exchange(
|
|
347
|
+
call,
|
|
348
|
+
"upload",
|
|
349
|
+
(idempotencyKey) => http.requestBytes({
|
|
350
|
+
method: "PUT",
|
|
351
|
+
path: `${call.descriptor.endpoint}/${call.intent.jobId}/content`,
|
|
352
|
+
body,
|
|
353
|
+
headers: { [HOSTED_FILE_INGEST_PROTOCOL.uploadTokenHeader]: token },
|
|
354
|
+
idempotencyKey,
|
|
355
|
+
timeoutMs: DEADLINE_MS.upload,
|
|
356
|
+
signal: call.signal
|
|
357
|
+
}),
|
|
358
|
+
parseHostedFileIngestJobView
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
async function refused(call, step, res) {
|
|
362
|
+
const detail = res.code === void 0 ? text(res.result) : `${text(res.result)} (${res.code})`;
|
|
363
|
+
if (CATALOG_STEPS.has(step) && COST_CODES.has(res.code ?? "")) {
|
|
364
|
+
return fail(await costRefusal(call, step, detail));
|
|
365
|
+
}
|
|
366
|
+
if (step === "prepare" && res.status === 422 || ENDED_CODES.has(res.code ?? "")) {
|
|
367
|
+
return fail(await deliver(call, stopped(call, step, detail, NEW_IMPORT)));
|
|
368
|
+
}
|
|
369
|
+
return { ok: false, status: res.status, result: stopped(call, step, detail, nextAfter(res)) };
|
|
370
|
+
}
|
|
371
|
+
async function costRefusal(call, step, detail) {
|
|
372
|
+
const auth = await post(
|
|
373
|
+
call,
|
|
374
|
+
"authorize",
|
|
375
|
+
`/${call.intent.jobId}/authorize`,
|
|
376
|
+
{},
|
|
377
|
+
parseHostedFileIngestAuthorization
|
|
378
|
+
);
|
|
379
|
+
if (!auth.ok) return stopped(call, step, detail);
|
|
380
|
+
await record(call, auth.value.state);
|
|
381
|
+
const { ceilingMicrocents, estimateMicrocents, settledMicrocents } = auth.value;
|
|
382
|
+
const cents = (microcents) => microcents === null ? "unknown" : `${formatCents(microcents, 2)} cents`;
|
|
383
|
+
return await authorizationStop(call, step, auth.value) ?? stopped(
|
|
384
|
+
call,
|
|
385
|
+
step,
|
|
386
|
+
`${detail}. The spending ceiling is ${cents(ceilingMicrocents)}, the estimated total ${cents(estimateMicrocents)}; charged so far ${formatCents(settledMicrocents, 4)} cents.`,
|
|
387
|
+
`Call this tool again with the same arguments and a maxCostCents above ${cents(ceilingMicrocents)} to continue.`
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
async function authorizationStop(call, step, auth, entryCount) {
|
|
391
|
+
const charged = `Charged so far: ${formatCents(auth.settledMicrocents, 4)} cents.`;
|
|
392
|
+
const chunks = entryCount === void 0 ? "its chunks" : `its ${entryCount} chunks`;
|
|
393
|
+
const stop = (detail, next) => stopped(call, step, `${detail} ${charged}`, next);
|
|
394
|
+
const resume = `call this tool again with the same arguments and maxCostCents of at least ${auth.estimateMicrocents === null ? "the estimate" : formatCents(auth.estimateMicrocents, 2)} to resume`;
|
|
395
|
+
switch (auth.state) {
|
|
396
|
+
case "quota_paused":
|
|
397
|
+
return stop(
|
|
398
|
+
`the import was paused before storing ${chunks}.`,
|
|
399
|
+
`${resume[0]?.toUpperCase()}${resume.slice(1)}.`
|
|
400
|
+
);
|
|
401
|
+
case "limit_exceeded":
|
|
402
|
+
return stop(
|
|
403
|
+
"the document exceeds the hosted limits for extracted text or chunks.",
|
|
404
|
+
`Call with cancel: true to abandon job ${call.intent.jobId}, then import the document in smaller parts.`
|
|
405
|
+
);
|
|
406
|
+
case "cancel_requested":
|
|
407
|
+
return stop(
|
|
408
|
+
"the job is being cancelled.",
|
|
409
|
+
"Call this tool with cancel: true to confirm the cancellation."
|
|
410
|
+
);
|
|
411
|
+
case "cancelled":
|
|
412
|
+
case "expired":
|
|
413
|
+
case "failed":
|
|
414
|
+
return deliver(call, stop(`the job is ${auth.state}.`, NEW_IMPORT));
|
|
415
|
+
}
|
|
416
|
+
if (auth.authorized) return void 0;
|
|
417
|
+
if (auth.estimateMicrocents === null) {
|
|
418
|
+
return stop("the server has no cost estimate for the job yet.", RESUME);
|
|
419
|
+
}
|
|
420
|
+
const estimate = formatCents(auth.estimateMicrocents, 2);
|
|
421
|
+
const ceiling = auth.ceilingMicrocents === null ? "" : ` The current ceiling is ${formatCents(auth.ceilingMicrocents, 2)} cents.`;
|
|
422
|
+
return stop(
|
|
423
|
+
`storing ${chunks} needs your approval: the estimated cost is ${estimate} cents.${ceiling}`,
|
|
424
|
+
`Call this tool again with the same arguments and maxCostCents of at least ${estimate} to continue.`
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
async function commitPages(call, job, manifest) {
|
|
428
|
+
const committed = new Set(job.committedPages);
|
|
429
|
+
for (const page of manifest.pages) {
|
|
430
|
+
if (committed.has(page.pageIndex)) continue;
|
|
431
|
+
const commit = await post(
|
|
432
|
+
call,
|
|
433
|
+
"commit",
|
|
434
|
+
`/${call.intent.jobId}/commit`,
|
|
435
|
+
{ manifestSha256: manifest.manifestSha256, page },
|
|
436
|
+
parseHostedFileIngestCommitResult
|
|
437
|
+
);
|
|
438
|
+
if (!commit.ok) return commit.result;
|
|
439
|
+
}
|
|
440
|
+
return void 0;
|
|
441
|
+
}
|
|
442
|
+
async function finish(call, job, maxCostMicrocents) {
|
|
443
|
+
const manifest = job.manifest;
|
|
444
|
+
if (!manifest) return stopped(call, "prepare", "the prepared job has no manifest");
|
|
445
|
+
const auth = await post(
|
|
446
|
+
call,
|
|
447
|
+
"authorize",
|
|
448
|
+
`/${call.intent.jobId}/authorize`,
|
|
449
|
+
maxCostMicrocents === void 0 ? {} : { maxCostMicrocents },
|
|
450
|
+
parseHostedFileIngestAuthorization
|
|
451
|
+
);
|
|
452
|
+
if (!auth.ok) return auth.result;
|
|
453
|
+
await record(call, auth.value.state);
|
|
454
|
+
const halted = await authorizationStop(call, "authorize", auth.value, manifest.entryCount) ?? await commitPages(call, job, manifest);
|
|
455
|
+
if (halted) return halted;
|
|
456
|
+
const publish = await post(
|
|
457
|
+
call,
|
|
458
|
+
"publish",
|
|
459
|
+
`/${call.intent.jobId}/publish`,
|
|
460
|
+
{ manifestSha256: manifest.manifestSha256, entryCount: manifest.entryCount },
|
|
461
|
+
parseHostedFileIngestPublishResult
|
|
462
|
+
);
|
|
463
|
+
if (!publish.ok) return publish.result;
|
|
464
|
+
await record(call, "published");
|
|
465
|
+
return deliver(call, published(call, publish.value));
|
|
466
|
+
}
|
|
467
|
+
async function ended(call, job) {
|
|
468
|
+
if (job.state === "published") {
|
|
469
|
+
return job.publication ? deliver(call, published(call, job.publication)) : stopped(call, "begin", "the published job has no publication result");
|
|
470
|
+
}
|
|
471
|
+
const what = job.state === "failed" ? `failed: ${job.error ? `${job.error.message} (${job.error.code})` : "no reason given"}` : `is ${job.state}.`;
|
|
472
|
+
return deliver(call, mcpText(`Import of ${label(call.intent)} ${what}
|
|
473
|
+
${NEW_IMPORT}`, true));
|
|
474
|
+
}
|
|
475
|
+
async function ingest(call, maxCostMicrocents) {
|
|
476
|
+
const { fingerprint, jobId } = call.intent;
|
|
477
|
+
let next = await post(
|
|
478
|
+
call,
|
|
479
|
+
"begin",
|
|
480
|
+
"",
|
|
481
|
+
{
|
|
482
|
+
jobId,
|
|
483
|
+
filename: call.filename,
|
|
484
|
+
byteLength: fingerprint.byteLength,
|
|
485
|
+
sha256: fingerprint.sha256,
|
|
486
|
+
...fingerprint.description === null ? {} : { description: fingerprint.description }
|
|
487
|
+
},
|
|
488
|
+
parseHostedFileIngestJobView
|
|
489
|
+
);
|
|
490
|
+
while (next.ok) {
|
|
491
|
+
const job = next.value;
|
|
492
|
+
await record(call, job.state);
|
|
493
|
+
switch (job.state) {
|
|
494
|
+
case "awaiting_upload":
|
|
495
|
+
next = await upload(call, job);
|
|
496
|
+
break;
|
|
497
|
+
case "uploaded":
|
|
498
|
+
next = await post(call, "prepare", `/${jobId}/prepare`, {}, parseHostedFileIngestJobView);
|
|
499
|
+
break;
|
|
500
|
+
case "prepared":
|
|
501
|
+
return finish(call, job, maxCostMicrocents);
|
|
502
|
+
default:
|
|
503
|
+
return ended(call, job);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return next.result;
|
|
507
|
+
}
|
|
508
|
+
async function cancel(call) {
|
|
509
|
+
const res = await post(
|
|
510
|
+
call,
|
|
511
|
+
"cancel",
|
|
512
|
+
`/${call.intent.jobId}/cancel`,
|
|
513
|
+
{},
|
|
514
|
+
parseHostedFileIngestAuthorization
|
|
515
|
+
);
|
|
516
|
+
if (!res.ok) {
|
|
517
|
+
if (res.status !== 404) return res.result;
|
|
518
|
+
return deliver(
|
|
519
|
+
call,
|
|
520
|
+
mcpText(`The server has no ${label(call.intent)}; nothing to cancel.
|
|
521
|
+
${NEW_IMPORT}`, true)
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
const { state, settledMicrocents } = res.value;
|
|
525
|
+
await record(call, state);
|
|
526
|
+
const charged = `Charged: ${formatCents(settledMicrocents, 4)} cents.`;
|
|
527
|
+
switch (state) {
|
|
528
|
+
case "cancelled":
|
|
529
|
+
return deliver(
|
|
530
|
+
call,
|
|
531
|
+
mcpText(`Cancelled ${label(call.intent)}. ${charged}
|
|
532
|
+
${NEW_IMPORT}`, true)
|
|
533
|
+
);
|
|
534
|
+
case "published":
|
|
535
|
+
case "expired":
|
|
536
|
+
case "failed":
|
|
537
|
+
return deliver(
|
|
538
|
+
call,
|
|
539
|
+
mcpText(
|
|
540
|
+
`${label(call.intent)} was already ${state}; nothing to cancel. ${charged}
|
|
541
|
+
${NEW_IMPORT}`,
|
|
542
|
+
true
|
|
543
|
+
)
|
|
544
|
+
);
|
|
545
|
+
default:
|
|
546
|
+
return mcpText(
|
|
547
|
+
`Cancel of ${label(call.intent)} is ${state}: it stops once outstanding work settles. ${charged}
|
|
548
|
+
Call this tool with cancel: true again to confirm.`,
|
|
549
|
+
true
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
async function openIntent(descriptor, args, existing) {
|
|
554
|
+
const size = await stat(args.path).then(
|
|
555
|
+
(info) => info.size,
|
|
556
|
+
(err) => err
|
|
557
|
+
);
|
|
558
|
+
if (existing) return matchIntent(existing, args, size);
|
|
559
|
+
if (typeof size !== "number") return refuse(`Failed to read ${args.path}: ${size.message}.`);
|
|
560
|
+
if (size === 0) return refuse(`${args.path} is empty.`);
|
|
561
|
+
if (size > descriptor.maxBytes) {
|
|
562
|
+
return refuse(`File too large: ${size} bytes > ${descriptor.maxBytes} cap.`);
|
|
563
|
+
}
|
|
564
|
+
try {
|
|
565
|
+
requireHostedFileIngestFilename(basename(args.path), "the file name");
|
|
566
|
+
} catch (err) {
|
|
567
|
+
return refuse(`Cannot import ${args.path}: ${errorText(err)}.`);
|
|
568
|
+
}
|
|
569
|
+
const { byteLength, sha256 } = await hashFile(args.path);
|
|
570
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
571
|
+
const intent = {
|
|
572
|
+
version: 1,
|
|
573
|
+
jobId: randomUUID2(),
|
|
574
|
+
endpoint: origin,
|
|
575
|
+
fingerprint: {
|
|
576
|
+
path: args.path,
|
|
577
|
+
byteLength,
|
|
578
|
+
sha256,
|
|
579
|
+
description: args.description,
|
|
580
|
+
namespaceId: args.namespaceId
|
|
581
|
+
},
|
|
582
|
+
lastState: null,
|
|
583
|
+
delivered: false,
|
|
584
|
+
createdAt: now,
|
|
585
|
+
updatedAt: now
|
|
586
|
+
};
|
|
587
|
+
await intents.save(intent);
|
|
588
|
+
return { ok: true, value: intent };
|
|
589
|
+
}
|
|
590
|
+
return async (descriptor, rawArgs, signal) => {
|
|
591
|
+
const args = parseArgs(rawArgs);
|
|
592
|
+
if (typeof args === "string") return mcpText(args, true);
|
|
593
|
+
let intent;
|
|
594
|
+
const callOn = (value) => ({
|
|
595
|
+
descriptor,
|
|
596
|
+
intent: value,
|
|
597
|
+
filename: basename(value.fingerprint.path),
|
|
598
|
+
signal,
|
|
599
|
+
retryUntil: Date.now() + RETRY_BUDGET_MS
|
|
600
|
+
});
|
|
601
|
+
try {
|
|
602
|
+
await guard();
|
|
603
|
+
const existing = await intents.findUndelivered(origin, args.path, args.namespaceId);
|
|
604
|
+
if (args.cancel) {
|
|
605
|
+
if (!existing) return mcpText(`No hosted import of ${args.path} to cancel.`, true);
|
|
606
|
+
intent = existing;
|
|
607
|
+
return await cancel(callOn(intent));
|
|
608
|
+
}
|
|
609
|
+
const opened = await openIntent(descriptor, args, existing);
|
|
610
|
+
if (!opened.ok) return opened.result;
|
|
611
|
+
intent = opened.value;
|
|
612
|
+
return await ingest(callOn(intent), args.maxCostMicrocents);
|
|
613
|
+
} catch (err) {
|
|
614
|
+
return mcpText(intent ? `${errorText(err)}
|
|
615
|
+
job ${intent.jobId}` : errorText(err), true);
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// src/mcp/tools/shared.ts
|
|
621
|
+
var LOCAL_UPLOAD_META_KEY = "pyx.dev/local-upload";
|
|
622
|
+
|
|
623
|
+
// src/mcp/proxy-server.ts
|
|
624
|
+
var REMOTE_MCP_PATH = "/mcp";
|
|
625
|
+
function createMcpIdempotentFetch(fetchImpl = fetch, sessionNonce = randomUUID3()) {
|
|
626
|
+
const wrapped = async (input, init) => {
|
|
627
|
+
const upstream = input instanceof Request ? input : new Request(String(input), init);
|
|
628
|
+
const method = init?.method ?? upstream.method;
|
|
629
|
+
const body = init?.body ?? (input instanceof Request ? await input.clone().text() : void 0);
|
|
630
|
+
const headers = new Headers(init?.headers ?? upstream.headers);
|
|
631
|
+
if (method.toUpperCase() === "POST" && !headers.has("Idempotency-Key")) {
|
|
632
|
+
if (typeof body !== "string") {
|
|
633
|
+
throw new Error("remote MCP POST body must be text for stable request idempotency");
|
|
634
|
+
}
|
|
635
|
+
const key = createHash2("sha256").update("pyx-mcp-idempotency-v1\0").update(sessionNonce).update("\0").update(body).digest("hex");
|
|
636
|
+
headers.set("Idempotency-Key", `mcp-${key}`);
|
|
637
|
+
}
|
|
638
|
+
return fetchImpl(input, { ...init, method, headers, body });
|
|
639
|
+
};
|
|
640
|
+
return Object.assign(wrapped, { preconnect: fetch.preconnect });
|
|
641
|
+
}
|
|
642
|
+
function resultText(result) {
|
|
643
|
+
return result.content.map((c) => c.type === "text" ? c.text : "").join(" ").trim();
|
|
644
|
+
}
|
|
645
|
+
function createCredentialGuard(readCredentials, snapshot) {
|
|
646
|
+
return async () => {
|
|
647
|
+
const read = await readCredentials();
|
|
648
|
+
if (!read.ok) {
|
|
649
|
+
throw new Error(
|
|
650
|
+
`${resultText(read.result) || "pyx-memory credentials are unavailable. Run: pyx-mem login"}
|
|
651
|
+
This attempt was not sent. Then reconnect the pyx-memory MCP server.`
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
if (read.credentials.endpoint !== snapshot.endpoint || read.credentials.apiKey !== snapshot.apiKey) {
|
|
655
|
+
throw new Error(
|
|
656
|
+
"pyx-memory credentials changed since this MCP session started (different endpoint or account). This attempt was not sent. Reconnect the pyx-memory MCP server to use the current sign-in."
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
function createGuardedFetch(guard, fetchImpl = fetch) {
|
|
662
|
+
const wrapped = async (input, init) => {
|
|
663
|
+
await guard();
|
|
664
|
+
return fetchImpl(input, init);
|
|
665
|
+
};
|
|
666
|
+
return Object.assign(wrapped, { preconnect: fetch.preconnect });
|
|
667
|
+
}
|
|
668
|
+
var RETRYABLE_MCP_METHODS = /* @__PURE__ */ new Set([
|
|
669
|
+
"initialize",
|
|
670
|
+
"notifications/initialized",
|
|
671
|
+
"tools/list",
|
|
672
|
+
"prompts/list"
|
|
673
|
+
]);
|
|
674
|
+
var MCP_RETRY_BUDGET_MS = 2e4;
|
|
675
|
+
var MIN_RESEND_SPACING_MS = 1e3;
|
|
676
|
+
function isRetryableMcpMessage(input, init) {
|
|
677
|
+
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
|
|
678
|
+
if (method.toUpperCase() !== "POST" || typeof init?.body !== "string") return false;
|
|
679
|
+
let message;
|
|
680
|
+
try {
|
|
681
|
+
message = JSON.parse(init.body);
|
|
682
|
+
} catch {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
if (typeof message !== "object" || message === null) return false;
|
|
686
|
+
const { method: rpc } = message;
|
|
687
|
+
return typeof rpc === "string" && RETRYABLE_MCP_METHODS.has(rpc);
|
|
688
|
+
}
|
|
689
|
+
function resendDelayMs(res, deadline) {
|
|
690
|
+
if (res.status !== 429 && res.status !== 503) return void 0;
|
|
691
|
+
const retryAfterMs2 = parseRetryAfterMs(res.headers.get("retry-after"));
|
|
692
|
+
if (retryAfterMs2 === void 0) return void 0;
|
|
693
|
+
const waitMs = Math.max(retryAfterMs2, MIN_RESEND_SPACING_MS);
|
|
694
|
+
return Date.now() + waitMs > deadline ? void 0 : waitMs;
|
|
695
|
+
}
|
|
696
|
+
function createMcpRetryAfterFetch(fetchImpl = fetch, retryUntil = () => Date.now() + MCP_RETRY_BUDGET_MS) {
|
|
697
|
+
const wrapped = async (input, init) => {
|
|
698
|
+
if (!isRetryableMcpMessage(input, init)) return fetchImpl(input, init);
|
|
699
|
+
const deadline = retryUntil();
|
|
700
|
+
const signal = init?.signal ?? (input instanceof Request ? input.signal : void 0);
|
|
701
|
+
for (; ; ) {
|
|
702
|
+
const res = await fetchImpl(input, init);
|
|
703
|
+
const waitMs = resendDelayMs(res, deadline);
|
|
704
|
+
if (waitMs === void 0) return res;
|
|
705
|
+
await res.body?.cancel();
|
|
706
|
+
await delay2(waitMs, void 0, { signal });
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
return Object.assign(wrapped, { preconnect: fetch.preconnect });
|
|
710
|
+
}
|
|
711
|
+
async function connectRemoteMcp(client, url, apiKey, fetchImpl, budgetMs = MCP_RETRY_BUDGET_MS) {
|
|
712
|
+
const connectUntil = Date.now() + budgetMs;
|
|
713
|
+
let connecting = true;
|
|
714
|
+
const transport = new StreamableHTTPClientTransport(url, {
|
|
715
|
+
requestInit: { headers: { Authorization: `Bearer ${apiKey}` } },
|
|
716
|
+
fetch: createMcpIdempotentFetch(
|
|
717
|
+
createMcpRetryAfterFetch(
|
|
718
|
+
fetchImpl,
|
|
719
|
+
() => connecting ? connectUntil : Date.now() + budgetMs
|
|
720
|
+
)
|
|
721
|
+
)
|
|
722
|
+
});
|
|
723
|
+
try {
|
|
724
|
+
await client.connect(transport);
|
|
725
|
+
} finally {
|
|
726
|
+
connecting = false;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
function extractLocalUpload(meta) {
|
|
730
|
+
if (meta === null || typeof meta !== "object") return void 0;
|
|
731
|
+
const descriptor = meta[LOCAL_UPLOAD_META_KEY];
|
|
732
|
+
if (descriptor === null || typeof descriptor !== "object") return void 0;
|
|
733
|
+
const obj = descriptor;
|
|
734
|
+
if (typeof obj.pathArg !== "string" || typeof obj.endpoint !== "string" || typeof obj.fileField !== "string") {
|
|
735
|
+
return void 0;
|
|
736
|
+
}
|
|
737
|
+
if (!obj.endpoint.startsWith("/") || obj.endpoint.startsWith("//")) {
|
|
738
|
+
return void 0;
|
|
739
|
+
}
|
|
740
|
+
return obj;
|
|
741
|
+
}
|
|
742
|
+
async function readCappedLocalFile(rawPath, descriptor) {
|
|
743
|
+
if (typeof rawPath !== "string" || rawPath.length === 0) {
|
|
744
|
+
return {
|
|
745
|
+
ok: false,
|
|
746
|
+
result: mcpText(`\`${descriptor.pathArg}\` must be a non-empty local file path.`, true)
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
const abs = isAbsolute2(rawPath) ? rawPath : resolve2(rawPath);
|
|
750
|
+
try {
|
|
751
|
+
if (typeof descriptor.maxBytes === "number") {
|
|
752
|
+
const info = await stat2(abs);
|
|
753
|
+
if (info.size > descriptor.maxBytes) {
|
|
754
|
+
return {
|
|
755
|
+
ok: false,
|
|
756
|
+
result: mcpText(`File too large: ${info.size} bytes > ${descriptor.maxBytes} cap.`, true)
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return { ok: true, bytes: await readFile2(abs), name: basename2(abs) };
|
|
761
|
+
} catch (err) {
|
|
762
|
+
return {
|
|
763
|
+
ok: false,
|
|
764
|
+
result: mcpText(
|
|
765
|
+
`Failed to read ${abs}: ${err instanceof Error ? err.message : String(err)}.`,
|
|
766
|
+
true
|
|
767
|
+
)
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
function createLocalFileUploader(credentials, readCredentials, fetchImpl = fetch) {
|
|
772
|
+
const guard = createCredentialGuard(readCredentials, credentials);
|
|
773
|
+
return async (descriptor, args) => {
|
|
774
|
+
try {
|
|
775
|
+
await guard();
|
|
776
|
+
const file = await readCappedLocalFile(args[descriptor.pathArg], descriptor);
|
|
777
|
+
if (!file.ok) return file.result;
|
|
778
|
+
const http = createHttpClient(credentials, fetchImpl);
|
|
779
|
+
const res = await http.requestMultipart({
|
|
780
|
+
path: descriptor.endpoint,
|
|
781
|
+
formData: async () => {
|
|
782
|
+
await guard();
|
|
783
|
+
const form = new FormData();
|
|
784
|
+
form.set(descriptor.fileField, new File([new Uint8Array(file.bytes)], file.name));
|
|
785
|
+
for (const key of descriptor.optionalArgs ?? []) {
|
|
786
|
+
const value = args[key];
|
|
787
|
+
if (typeof value === "string" && value.length > 0) form.set(key, value);
|
|
788
|
+
}
|
|
789
|
+
return form;
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
return res.ok ? mcpJson(res.data) : res.result;
|
|
793
|
+
} catch (err) {
|
|
794
|
+
return mcpText(err instanceof Error ? err.message : String(err), true);
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
function createProxyServer(client, version, uploadLocalFile, hostedFileIngest) {
|
|
799
|
+
const caps = client.getServerCapabilities();
|
|
800
|
+
const server = new Server(client.getServerVersion() ?? { name: "pyx-memory", version }, {
|
|
801
|
+
capabilities: {
|
|
802
|
+
// Advertise the PRESENCE of tools/prompts, but NOT their `listChanged`
|
|
803
|
+
// sub-capability: this proxy does not relay the remote's list_changed
|
|
804
|
+
// notifications, so claiming listChanged would be a capability the host
|
|
805
|
+
// could rely on but we never honor (Codex: keep the proxy transparent).
|
|
806
|
+
...caps?.tools ? { tools: {} } : {},
|
|
807
|
+
...caps?.prompts ? { prompts: {} } : {}
|
|
808
|
+
},
|
|
809
|
+
instructions: client.getInstructions()
|
|
810
|
+
});
|
|
811
|
+
const bridges = /* @__PURE__ */ new Map();
|
|
812
|
+
const bridging = uploadLocalFile !== void 0 || hostedFileIngest !== void 0;
|
|
813
|
+
const bridgeOf = (meta) => {
|
|
814
|
+
const hosted = hostedFileIngest ? parseHostedFileIngestDescriptor(meta) : void 0;
|
|
815
|
+
if (hosted) return { kind: "hosted", descriptor: hosted };
|
|
816
|
+
const local = uploadLocalFile ? extractLocalUpload(meta) : void 0;
|
|
817
|
+
return local ? { kind: "local", descriptor: local } : void 0;
|
|
818
|
+
};
|
|
819
|
+
let listedOnce = false;
|
|
820
|
+
let listingsStarted = 0;
|
|
821
|
+
let listingApplied = 0;
|
|
822
|
+
const listAndRefresh = async (params) => {
|
|
823
|
+
const listing = ++listingsStarted;
|
|
824
|
+
const result = await client.listTools(params);
|
|
825
|
+
const { tools, nextCursor } = result;
|
|
826
|
+
if (!Array.isArray(tools) || listing < listingApplied) return result;
|
|
827
|
+
listingApplied = listing;
|
|
828
|
+
listedOnce = true;
|
|
829
|
+
if (params?.cursor === void 0 && nextCursor === void 0) bridges.clear();
|
|
830
|
+
for (const tool of tools) {
|
|
831
|
+
const bridge = bridgeOf(tool._meta);
|
|
832
|
+
if (bridge) bridges.set(tool.name, bridge);
|
|
833
|
+
else bridges.delete(tool.name);
|
|
834
|
+
}
|
|
835
|
+
return result;
|
|
836
|
+
};
|
|
837
|
+
if (caps?.tools) {
|
|
838
|
+
server.setRequestHandler(ListToolsRequestSchema, (req) => listAndRefresh(req.params));
|
|
839
|
+
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
|
|
840
|
+
if (bridging && !listedOnce) {
|
|
841
|
+
try {
|
|
842
|
+
await listAndRefresh();
|
|
843
|
+
} catch {
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
const bridge = bridges.get(req.params.name);
|
|
847
|
+
const args = req.params.arguments ?? {};
|
|
848
|
+
if (bridge?.kind === "hosted" && hostedFileIngest) {
|
|
849
|
+
return await hostedFileIngest(bridge.descriptor, args, extra.signal);
|
|
850
|
+
}
|
|
851
|
+
if (bridge?.kind === "local" && uploadLocalFile) {
|
|
852
|
+
return await uploadLocalFile(bridge.descriptor, args);
|
|
853
|
+
}
|
|
854
|
+
const actor = normalizeMemoryActorLabel(server.getClientVersion()?.name);
|
|
855
|
+
const params = { ...req.params };
|
|
856
|
+
if (actor !== null || params._meta !== void 0) {
|
|
857
|
+
params._meta = { ...params._meta };
|
|
858
|
+
delete params._meta[MCP_ACTOR_META_KEY];
|
|
859
|
+
if (actor !== null) params._meta[MCP_ACTOR_META_KEY] = actor;
|
|
860
|
+
}
|
|
861
|
+
return client.callTool(params);
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
if (caps?.prompts) {
|
|
865
|
+
server.setRequestHandler(
|
|
866
|
+
ListPromptsRequestSchema,
|
|
867
|
+
(req) => client.listPrompts(req.params)
|
|
868
|
+
);
|
|
869
|
+
server.setRequestHandler(
|
|
870
|
+
GetPromptRequestSchema,
|
|
871
|
+
(req) => client.getPrompt(req.params)
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
return server;
|
|
875
|
+
}
|
|
876
|
+
async function runMcpProxyServer(opts) {
|
|
877
|
+
const version = opts.version ?? (true ? "1.27.1" : "0.0.0-dev");
|
|
878
|
+
const read = await opts.readCredentials();
|
|
879
|
+
if (!read.ok) {
|
|
880
|
+
throw new Error(
|
|
881
|
+
resultText(read.result) || "pyx-memory credentials are unavailable. Run: pyx-mem login"
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
const { endpoint, apiKey } = read.credentials;
|
|
885
|
+
const url = new URL(`${endpoint.replace(/\/+$/, "")}${REMOTE_MCP_PATH}`);
|
|
886
|
+
const guard = createCredentialGuard(opts.readCredentials, read.credentials);
|
|
887
|
+
const client = new McpClient({ name: "pyx-mem-proxy", version }, { capabilities: {} });
|
|
888
|
+
try {
|
|
889
|
+
await connectRemoteMcp(client, url, apiKey, createGuardedFetch(guard));
|
|
890
|
+
} catch (err) {
|
|
891
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
892
|
+
throw new Error(
|
|
893
|
+
`Could not reach the pyx-memory remote MCP at ${url.href}: ${msg}. Verify the endpoint and key with \`pyx-mem doctor\`.`
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
try {
|
|
897
|
+
const server = createProxyServer(
|
|
898
|
+
client,
|
|
899
|
+
version,
|
|
900
|
+
createLocalFileUploader(read.credentials, opts.readCredentials),
|
|
901
|
+
createHostedFileIngestDriver(read.credentials, guard, createFileIngestIntentStore())
|
|
902
|
+
);
|
|
903
|
+
const transport = new StdioServerTransport();
|
|
904
|
+
const closed = new Promise((resolve3) => {
|
|
905
|
+
transport.onclose = () => resolve3();
|
|
906
|
+
});
|
|
907
|
+
process.stdin.once("end", () => void transport.close());
|
|
908
|
+
await server.connect(transport);
|
|
909
|
+
void opts.onConnected?.(client);
|
|
910
|
+
await closed;
|
|
911
|
+
} finally {
|
|
912
|
+
await client.close().catch(() => {
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// src/cli/commands/mcp.ts
|
|
918
|
+
async function mcpCommand() {
|
|
919
|
+
const readCredentials = createReadCredentials(() => getDefaultKeychain());
|
|
920
|
+
try {
|
|
921
|
+
await runMcpProxyServer({
|
|
922
|
+
readCredentials,
|
|
923
|
+
onConnected: (client) => syncMemoryRulesFromServer(client)
|
|
924
|
+
});
|
|
925
|
+
return EXIT.OK;
|
|
926
|
+
} catch (err) {
|
|
927
|
+
process.stderr.write(`Internal error: ${err instanceof Error ? err.message : String(err)}
|
|
928
|
+
`);
|
|
929
|
+
return EXIT.USAGE;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
function resolveMcpMode(flags) {
|
|
933
|
+
if (flags.bundled === true) {
|
|
934
|
+
return {
|
|
935
|
+
ok: false,
|
|
936
|
+
error: "The bundled stdio MCP server was removed in v1.3.0. `pyx-mem mcp` now uses the hosted zero-touch path (server-owned tools + instructions; your key stays in the OS keychain). Drop `--bundled`. For a self-hosted server, point at it with `pyx-mem login --endpoint <url>` first."
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
return { ok: true };
|
|
940
|
+
}
|
|
941
|
+
export {
|
|
942
|
+
mcpCommand,
|
|
943
|
+
resolveMcpMode
|
|
944
|
+
};
|