@buildinternet/uploads 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -2
- package/dist/cli.js +27 -2
- package/dist/client.d.ts +48 -1
- package/dist/client.js +49 -12
- package/dist/commands/install.d.ts +8 -0
- package/dist/commands/install.js +133 -0
- package/dist/commands/mcp.d.ts +4 -0
- package/dist/commands/mcp.js +39 -0
- package/dist/commands.d.ts +47 -0
- package/dist/commands.js +150 -57
- package/dist/errors.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/io.d.ts +3 -0
- package/dist/io.js +9 -0
- package/dist/mcp/args.d.ts +4 -0
- package/dist/mcp/args.js +26 -0
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +109 -0
- package/dist/mcp/stdio.d.ts +3 -0
- package/dist/mcp/stdio.js +14 -0
- package/dist/mcp/tools.d.ts +10 -0
- package/dist/mcp/tools.js +386 -0
- package/package.json +12 -9
package/dist/commands.js
CHANGED
|
@@ -5,16 +5,9 @@ import { parseCommandArgs, flagString, flagBool, flagInt, UsageError, } from "./
|
|
|
5
5
|
import { resolvePutDefaults, workspaceMismatch, workspaceFromToken, } from "./config.js";
|
|
6
6
|
import { buildMarkdown } from "./embed.js";
|
|
7
7
|
import { UploadsError } from "./errors.js";
|
|
8
|
+
import { writeJson, writeStdout } from "./io.js";
|
|
8
9
|
import { ghAttachmentKey, ghKeyPrefix, attachmentsCommentBody, } from "./github.js";
|
|
9
10
|
import { resolveRepo, resolveCurrentPullRequest, execRunner, upsertAttachmentsComment, } from "./github-gh.js";
|
|
10
|
-
async function writeStdout(text) {
|
|
11
|
-
if (!process.stdout.write(text)) {
|
|
12
|
-
await new Promise((resolve) => process.stdout.once("drain", resolve));
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
async function writeJson(value) {
|
|
16
|
-
await writeStdout(JSON.stringify(value, null, 2) + "\n");
|
|
17
|
-
}
|
|
18
11
|
// --- put ---
|
|
19
12
|
const PUT_HELP = `uploads put <file> [options]
|
|
20
13
|
|
|
@@ -40,31 +33,30 @@ Examples:
|
|
|
40
33
|
uploads --env-file .env put ./shot.png
|
|
41
34
|
uploads --env-file .env put ./after.png --pr 123 --comment
|
|
42
35
|
`;
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Turns a pr/issue pair (+ optional repo) into a GhTarget; undefined when
|
|
38
|
+
* neither is present. Shared by the CLI flags and the MCP tool arguments.
|
|
39
|
+
*/
|
|
40
|
+
export function makeGhTarget(pr, issue, repoArg, run) {
|
|
47
41
|
if (pr === undefined && issue === undefined)
|
|
48
42
|
return undefined;
|
|
49
43
|
if (pr !== undefined && issue !== undefined) {
|
|
50
44
|
throw new UsageError("--pr and --issue are mutually exclusive");
|
|
51
45
|
}
|
|
52
|
-
const repo = resolveRepo(
|
|
46
|
+
const repo = resolveRepo(repoArg, run);
|
|
53
47
|
return { repo, kind: pr !== undefined ? "pull" : "issues", num: (pr ?? issue) };
|
|
54
48
|
}
|
|
49
|
+
/** Reads --pr/--issue (+ --repo) into a GhTarget; undefined when neither flag is present. */
|
|
50
|
+
function ghTargetFromFlags(flags, run) {
|
|
51
|
+
return makeGhTarget(flagInt(flags, "--pr", "--pr"), flagInt(flags, "--issue", "--issue"), flagString(flags, "--repo"), run);
|
|
52
|
+
}
|
|
55
53
|
/**
|
|
56
54
|
* List every attachment under the target's prefix and create/update the
|
|
57
55
|
* managed comment. Throws on gh failure — callers decide whether that is
|
|
58
56
|
* fatal (`comment` command) or a warning (`put --comment`).
|
|
59
57
|
*/
|
|
60
|
-
async function syncAttachmentsComment(
|
|
61
|
-
const items =
|
|
62
|
-
let cursor;
|
|
63
|
-
do {
|
|
64
|
-
const page = await ctx.client.list({ prefix: ghKeyPrefix(target), cursor });
|
|
65
|
-
items.push(...page.items.map(({ key, url }) => ({ key, url })));
|
|
66
|
-
cursor = page.cursor ?? undefined;
|
|
67
|
-
} while (cursor);
|
|
58
|
+
export async function syncAttachmentsComment(client, target, run) {
|
|
59
|
+
const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url }) => ({ key, url }));
|
|
68
60
|
if (items.length === 0)
|
|
69
61
|
return { action: "skipped", count: 0 };
|
|
70
62
|
const body = attachmentsCommentBody(items);
|
|
@@ -124,7 +116,7 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
124
116
|
let commentError;
|
|
125
117
|
if (!parsed.flags.has("--no-comment")) {
|
|
126
118
|
try {
|
|
127
|
-
comment = await syncAttachmentsComment(ctx, target, run);
|
|
119
|
+
comment = await syncAttachmentsComment(ctx.client, target, run);
|
|
128
120
|
}
|
|
129
121
|
catch (err) {
|
|
130
122
|
commentError = err instanceof Error ? err.message : String(err);
|
|
@@ -230,7 +222,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
230
222
|
}
|
|
231
223
|
if (wantComment && ghTarget) {
|
|
232
224
|
try {
|
|
233
|
-
const sync = await syncAttachmentsComment(ctx, ghTarget, run);
|
|
225
|
+
const sync = await syncAttachmentsComment(ctx.client, ghTarget, run);
|
|
234
226
|
if (!ctx.quiet && format === "human") {
|
|
235
227
|
process.stderr.write(`>> attachments comment ${sync.action}\n`);
|
|
236
228
|
}
|
|
@@ -270,13 +262,8 @@ export async function runList(ctx, args, help = false, run = execRunner) {
|
|
|
270
262
|
const limit = flagInt(parsed.flags, "--limit", "--limit");
|
|
271
263
|
const cursor = flagString(parsed.flags, "--cursor");
|
|
272
264
|
if (flagBool(parsed.flags, "--all")) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
do {
|
|
276
|
-
const page = await ctx.client.list({ prefix, limit, cursor: next ?? undefined });
|
|
277
|
-
items.push(...page.items);
|
|
278
|
-
next = page.cursor;
|
|
279
|
-
} while (next);
|
|
265
|
+
// --all may start from a caller-provided --cursor and drains from there.
|
|
266
|
+
const items = await ctx.client.listAll({ prefix, limit, cursor });
|
|
280
267
|
if (ctx.json)
|
|
281
268
|
await writeJson({ items, cursor: null });
|
|
282
269
|
else
|
|
@@ -347,7 +334,7 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
|
|
|
347
334
|
const target = ghTargetFromFlags(parsed.flags, run);
|
|
348
335
|
if (!target)
|
|
349
336
|
throw new UsageError("comment requires --pr or --issue");
|
|
350
|
-
const result = await syncAttachmentsComment(ctx, target, run);
|
|
337
|
+
const result = await syncAttachmentsComment(ctx.client, target, run);
|
|
351
338
|
if (ctx.json) {
|
|
352
339
|
await writeJson({ ...target, ...result });
|
|
353
340
|
}
|
|
@@ -358,6 +345,83 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
|
|
|
358
345
|
}
|
|
359
346
|
return 0;
|
|
360
347
|
}
|
|
348
|
+
// --- usage / reconcile / purge ---
|
|
349
|
+
const USAGE_HELP = `uploads usage [--workspace <name>]
|
|
350
|
+
|
|
351
|
+
Show workspace storage and monthly upload counters (and limits when set).
|
|
352
|
+
|
|
353
|
+
Examples:
|
|
354
|
+
uploads --env-file .env usage
|
|
355
|
+
uploads usage --json
|
|
356
|
+
`;
|
|
357
|
+
export async function runUsage(ctx, args, help = false) {
|
|
358
|
+
if (help || parseCommandArgs(args).help) {
|
|
359
|
+
process.stderr.write(USAGE_HELP);
|
|
360
|
+
return 0;
|
|
361
|
+
}
|
|
362
|
+
const result = await ctx.client.usage();
|
|
363
|
+
if (ctx.json) {
|
|
364
|
+
await writeJson(result);
|
|
365
|
+
return 0;
|
|
366
|
+
}
|
|
367
|
+
const lines = [
|
|
368
|
+
`workspace: ${result.workspace}`,
|
|
369
|
+
`bytes: ${result.bytes}${result.maxStorageBytes != null ? ` / ${result.maxStorageBytes} (${result.storageRemainingBytes} remaining)` : ""}`,
|
|
370
|
+
`objects: ${result.objects}`,
|
|
371
|
+
`uploads: ${result.uploadsInPeriod} this period (${result.periodStart})${result.maxUploadsPerPeriod != null ? ` / ${result.maxUploadsPerPeriod} (${result.uploadsRemaining} remaining)` : ""}`,
|
|
372
|
+
`updated: ${result.updatedAt}`,
|
|
373
|
+
];
|
|
374
|
+
await writeStdout(lines.join("\n") + "\n");
|
|
375
|
+
return 0;
|
|
376
|
+
}
|
|
377
|
+
const RECONCILE_HELP = `uploads reconcile [--workspace <name>]
|
|
378
|
+
|
|
379
|
+
Rebuild ledger bytes/objects from storage (source of truth). Preserves the
|
|
380
|
+
monthly upload counter. Requires files:write.
|
|
381
|
+
|
|
382
|
+
Examples:
|
|
383
|
+
uploads --env-file .env reconcile
|
|
384
|
+
`;
|
|
385
|
+
export async function runReconcile(ctx, args, help = false) {
|
|
386
|
+
if (help || parseCommandArgs(args).help) {
|
|
387
|
+
process.stderr.write(RECONCILE_HELP);
|
|
388
|
+
return 0;
|
|
389
|
+
}
|
|
390
|
+
const result = await ctx.client.reconcile();
|
|
391
|
+
if (ctx.json) {
|
|
392
|
+
await writeJson(result);
|
|
393
|
+
return 0;
|
|
394
|
+
}
|
|
395
|
+
await writeStdout(result.changed
|
|
396
|
+
? `reconciled ${result.workspace}: ${result.previous.bytes}→${result.bytes} bytes, ${result.previous.objects}→${result.objects} objects\n`
|
|
397
|
+
: `reconciled ${result.workspace}: unchanged (${result.bytes} bytes, ${result.objects} objects)\n`);
|
|
398
|
+
return 0;
|
|
399
|
+
}
|
|
400
|
+
const PURGE_HELP = `uploads purge-expired [--workspace <name>]
|
|
401
|
+
|
|
402
|
+
Delete objects older than the workspace retentionDays setting, then reconcile.
|
|
403
|
+
Skips if retention is unset. Requires files:delete.
|
|
404
|
+
|
|
405
|
+
Examples:
|
|
406
|
+
uploads --env-file .env purge-expired
|
|
407
|
+
`;
|
|
408
|
+
export async function runPurgeExpired(ctx, args, help = false) {
|
|
409
|
+
if (help || parseCommandArgs(args).help) {
|
|
410
|
+
process.stderr.write(PURGE_HELP);
|
|
411
|
+
return 0;
|
|
412
|
+
}
|
|
413
|
+
const result = await ctx.client.purgeExpired();
|
|
414
|
+
if (ctx.json) {
|
|
415
|
+
await writeJson(result);
|
|
416
|
+
return 0;
|
|
417
|
+
}
|
|
418
|
+
if ("skipped" in result) {
|
|
419
|
+
await writeStdout(`skipped: ${result.reason}\n`);
|
|
420
|
+
return 0;
|
|
421
|
+
}
|
|
422
|
+
await writeStdout(`purged ${result.deleted} object(s), freed ${result.freedBytes} bytes (retention ${result.retentionDays}d)\n`);
|
|
423
|
+
return 0;
|
|
424
|
+
}
|
|
361
425
|
// --- health & doctor ---
|
|
362
426
|
const HEALTH_HELP = `uploads health
|
|
363
427
|
|
|
@@ -391,23 +455,20 @@ Examples:
|
|
|
391
455
|
uploads --env-file .env doctor
|
|
392
456
|
uploads --workspace acme --env-file .env doctor
|
|
393
457
|
`;
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
return 0;
|
|
398
|
-
}
|
|
399
|
-
const mismatch = workspaceMismatch(ctx.config);
|
|
458
|
+
/** Doctor's health + auth + workspace checks, shared by the CLI and the MCP tool. */
|
|
459
|
+
export async function buildDoctorReport(config, client) {
|
|
460
|
+
const mismatch = workspaceMismatch(config);
|
|
400
461
|
const hints = [];
|
|
401
462
|
if (mismatch)
|
|
402
463
|
hints.push(mismatch);
|
|
403
|
-
if (
|
|
464
|
+
if (config.apiUrl.includes("localhost") || config.apiUrl.includes("127.0.0.1")) {
|
|
404
465
|
hints.push("local API uses dev KV — prod tokens won't work unless minted with --local");
|
|
405
466
|
}
|
|
406
|
-
const health = await
|
|
467
|
+
const health = await client.health();
|
|
407
468
|
let authOk = false;
|
|
408
469
|
let authError;
|
|
409
470
|
try {
|
|
410
|
-
await
|
|
471
|
+
await client.list({ limit: 1 });
|
|
411
472
|
authOk = true;
|
|
412
473
|
}
|
|
413
474
|
catch (err) {
|
|
@@ -416,35 +477,67 @@ export async function runDoctor(ctx, args, help = false) {
|
|
|
416
477
|
hints.push("if this token works on api.uploads.sh, set UPLOADS_API_URL=https://api.uploads.sh");
|
|
417
478
|
}
|
|
418
479
|
}
|
|
419
|
-
|
|
420
|
-
|
|
480
|
+
let usage;
|
|
481
|
+
if (authOk) {
|
|
482
|
+
try {
|
|
483
|
+
const snap = await client.usage();
|
|
484
|
+
usage = {
|
|
485
|
+
ok: true,
|
|
486
|
+
bytes: snap.bytes,
|
|
487
|
+
objects: snap.objects,
|
|
488
|
+
uploadsInPeriod: snap.uploadsInPeriod,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
usage = {
|
|
493
|
+
ok: false,
|
|
494
|
+
error: err instanceof UploadsError ? err.message : String(err),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
421
497
|
}
|
|
422
|
-
|
|
498
|
+
if (!config.configExists && !config.token) {
|
|
499
|
+
hints.push(`run uploads setup to configure ${config.configPath}`);
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
423
502
|
ok: health.ok && authOk,
|
|
424
|
-
apiUrl:
|
|
425
|
-
workspace:
|
|
426
|
-
workspaceSource:
|
|
427
|
-
workspaceFromToken: workspaceFromToken(
|
|
428
|
-
configPath:
|
|
429
|
-
configExists:
|
|
503
|
+
apiUrl: config.apiUrl,
|
|
504
|
+
workspace: config.workspace,
|
|
505
|
+
workspaceSource: config.workspaceSource,
|
|
506
|
+
workspaceFromToken: workspaceFromToken(config.token),
|
|
507
|
+
configPath: config.configPath,
|
|
508
|
+
configExists: config.configExists,
|
|
430
509
|
health,
|
|
431
510
|
auth: { ok: authOk, error: authError },
|
|
511
|
+
usage,
|
|
512
|
+
warning: mismatch,
|
|
432
513
|
hints,
|
|
433
514
|
};
|
|
515
|
+
}
|
|
516
|
+
export async function runDoctor(ctx, args, help = false) {
|
|
517
|
+
if (help || parseCommandArgs(args).help) {
|
|
518
|
+
process.stderr.write(DOCTOR_HELP);
|
|
519
|
+
return 0;
|
|
520
|
+
}
|
|
521
|
+
const report = await buildDoctorReport(ctx.config, ctx.client);
|
|
434
522
|
if (ctx.json) {
|
|
435
523
|
await writeJson(report);
|
|
436
524
|
return report.ok ? 0 : 1;
|
|
437
525
|
}
|
|
438
526
|
const lines = [
|
|
439
|
-
`config: ${
|
|
440
|
-
`api: ${
|
|
441
|
-
`workspace: ${
|
|
442
|
-
`auth: ${
|
|
527
|
+
`config: ${report.configPath}${report.configExists ? "" : " (missing)"}`,
|
|
528
|
+
`api: ${report.apiUrl} (${report.health.ok ? "ok" : "failed"})`,
|
|
529
|
+
`workspace: ${report.workspace}`,
|
|
530
|
+
`auth: ${report.auth.ok ? "ok" : `failed — ${report.auth.error ?? "no token"}`}`,
|
|
443
531
|
];
|
|
444
|
-
if (
|
|
445
|
-
lines.push(
|
|
446
|
-
|
|
447
|
-
|
|
532
|
+
if (report.usage) {
|
|
533
|
+
lines.push(report.usage.ok
|
|
534
|
+
? `usage: ${report.usage.bytes} bytes, ${report.usage.objects} objects, ${report.usage.uploadsInPeriod} uploads this period`
|
|
535
|
+
: `usage: failed — ${report.usage.error ?? "unknown"}`);
|
|
536
|
+
}
|
|
537
|
+
if (report.warning)
|
|
538
|
+
lines.push(`warning: ${report.warning}`);
|
|
539
|
+
for (const h of report.hints)
|
|
540
|
+
if (h !== report.warning)
|
|
448
541
|
lines.push(`hint: ${h}`);
|
|
449
542
|
await writeStdout(lines.join("\n") + "\n");
|
|
450
543
|
return report.ok ? 0 : 1;
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type UploadsErrorCode = "MISSING_TOKEN" | "NO_PUBLIC_URL" | "NOT_FOUND" | "UNAUTHORIZED" | "INVALID_KEY" | "API_ERROR" | "NETWORK" | "USAGE";
|
|
1
|
+
export type UploadsErrorCode = "MISSING_TOKEN" | "NO_PUBLIC_URL" | "NOT_FOUND" | "UNAUTHORIZED" | "INVALID_KEY" | "STORAGE_QUOTA" | "UPLOAD_BUDGET" | "API_ERROR" | "NETWORK" | "USAGE";
|
|
2
2
|
export declare class UploadsError extends Error {
|
|
3
3
|
readonly code: UploadsErrorCode;
|
|
4
4
|
readonly status?: number;
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,6 @@ export { inferContentType, buildMarkdown } from "./embed.js";
|
|
|
2
2
|
export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey } from "./keys.js";
|
|
3
3
|
export { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, defaultConfigPath, resolveConfigPath, loadConfigFile, loadEnvFile, resolveApiUrl, resolveConfig, describeConfigSources, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, workspaceFromToken, workspaceMismatch, type UploadsClientConfig, type ResolvedConfig, type WorkspaceSource, type ConfigValueSource, type ConfigSources, type UploadsConfigKey, type UploadsConfigValues, type PutDefaults, } from "./config.js";
|
|
4
4
|
export { UploadsError, type UploadsErrorCode } from "./errors.js";
|
|
5
|
-
export { createUploadsClient, type UploadsClient, type PutOptions, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type HealthResult, } from "./client.js";
|
|
5
|
+
export { createUploadsClient, type UploadsClient, type PutOptions, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type HealthResult, type UsageResult, type ReconcileResult, type PurgeExpiredResult, type PurgeExpiredResponse, } from "./client.js";
|
|
6
6
|
export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, isValidRepo, parseRepoFromRemoteUrl, type AttachmentItem, type GhTarget, type GhTargetKind, } from "./github.js";
|
|
7
7
|
export { execRunner, resolveRepo, upsertAttachmentsComment, type CommandRunner, } from "./github-gh.js";
|
package/dist/io.d.ts
ADDED
package/dist/io.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Backpressure-aware stdout helpers shared by the CLI commands and the stdio MCP transport. */
|
|
2
|
+
export async function writeStdout(text) {
|
|
3
|
+
if (!process.stdout.write(text)) {
|
|
4
|
+
await new Promise((resolve) => process.stdout.once("drain", resolve));
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export async function writeJson(value) {
|
|
8
|
+
await writeStdout(JSON.stringify(value, null, 2) + "\n");
|
|
9
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type ToolArgs = Record<string, unknown>;
|
|
2
|
+
export declare function usage(msg: string): never;
|
|
3
|
+
export declare function optString(args: ToolArgs, name: string): string | undefined;
|
|
4
|
+
export declare function optPosInt(args: ToolArgs, name: string): number | undefined;
|
package/dist/mcp/args.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argument helpers shared by the stdio MCP tool set (./tools.ts) and the
|
|
3
|
+
* remote worker's tool set (apps/mcp). Runtime-agnostic — usable from
|
|
4
|
+
* Workers as well as Node.
|
|
5
|
+
*/
|
|
6
|
+
import { UploadsError } from "../errors.js";
|
|
7
|
+
export function usage(msg) {
|
|
8
|
+
throw new UploadsError(msg, "USAGE");
|
|
9
|
+
}
|
|
10
|
+
export function optString(args, name) {
|
|
11
|
+
const v = args[name];
|
|
12
|
+
if (v === undefined || v === null)
|
|
13
|
+
return undefined;
|
|
14
|
+
if (typeof v !== "string")
|
|
15
|
+
usage(`${name} must be a string`);
|
|
16
|
+
return v;
|
|
17
|
+
}
|
|
18
|
+
export function optPosInt(args, name) {
|
|
19
|
+
const v = args[name];
|
|
20
|
+
if (v === undefined || v === null)
|
|
21
|
+
return undefined;
|
|
22
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v <= 0) {
|
|
23
|
+
usage(`${name} must be a positive integer`);
|
|
24
|
+
}
|
|
25
|
+
return v;
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { optPosInt, optString, usage, type ToolArgs } from "./args.js";
|
|
2
|
+
export interface McpTool {
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
/** Hand-written JSON Schema for the tool's arguments. */
|
|
6
|
+
inputSchema: Record<string, unknown>;
|
|
7
|
+
handler: (args: Record<string, unknown>) => Promise<unknown>;
|
|
8
|
+
}
|
|
9
|
+
export interface McpServer {
|
|
10
|
+
/** Handle one JSON-RPC line. Undefined for notifications / client responses. */
|
|
11
|
+
handleLine(line: string): Promise<string | undefined>;
|
|
12
|
+
}
|
|
13
|
+
export declare function createMcpServer(opts: {
|
|
14
|
+
serverInfo: {
|
|
15
|
+
name: string;
|
|
16
|
+
version: string;
|
|
17
|
+
};
|
|
18
|
+
tools: McpTool[];
|
|
19
|
+
}): McpServer;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, dependency-free MCP (Model Context Protocol) server core.
|
|
3
|
+
*
|
|
4
|
+
* Transport is one JSON-RPC 2.0 message per line/request. This module is
|
|
5
|
+
* transport- and runtime-agnostic (usable from Workers as well as Node) —
|
|
6
|
+
* `handleLine` takes a raw message string and returns the serialized response
|
|
7
|
+
* (or undefined when no response is due), so it is directly testable. The
|
|
8
|
+
* stdio transport lives in ./stdio.ts; logs must never go to stdout.
|
|
9
|
+
*/
|
|
10
|
+
import { UploadsError } from "../errors.js";
|
|
11
|
+
export { optPosInt, optString, usage } from "./args.js";
|
|
12
|
+
const SUPPORTED_PROTOCOL_VERSIONS = new Set(["2025-06-18", "2025-03-26", "2024-11-05"]);
|
|
13
|
+
const LATEST_PROTOCOL_VERSION = "2025-06-18";
|
|
14
|
+
function response(id, result) {
|
|
15
|
+
return JSON.stringify({ jsonrpc: "2.0", id, result });
|
|
16
|
+
}
|
|
17
|
+
function errorResponse(id, code, message) {
|
|
18
|
+
return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
|
|
19
|
+
}
|
|
20
|
+
/** Tool failures become tool results (isError), never JSON-RPC errors. */
|
|
21
|
+
function toolErrorText(err) {
|
|
22
|
+
if (err instanceof UploadsError)
|
|
23
|
+
return `${err.message} (${err.code})`;
|
|
24
|
+
return err instanceof Error ? err.message : String(err);
|
|
25
|
+
}
|
|
26
|
+
export function createMcpServer(opts) {
|
|
27
|
+
const { serverInfo, tools } = opts;
|
|
28
|
+
async function callTool(id, params) {
|
|
29
|
+
const name = params.name;
|
|
30
|
+
const tool = typeof name === "string" ? tools.find((t) => t.name === name) : undefined;
|
|
31
|
+
if (!tool)
|
|
32
|
+
return errorResponse(id, -32602, `unknown tool: ${String(name ?? "(missing)")}`);
|
|
33
|
+
const args = params.arguments ?? {};
|
|
34
|
+
if (typeof args !== "object" || args === null || Array.isArray(args)) {
|
|
35
|
+
return errorResponse(id, -32602, "tool arguments must be an object");
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const result = await tool.handler(args);
|
|
39
|
+
return response(id, {
|
|
40
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
41
|
+
structuredContent: result,
|
|
42
|
+
isError: false,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
return response(id, {
|
|
47
|
+
content: [{ type: "text", text: toolErrorText(err) }],
|
|
48
|
+
isError: true,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
async handleLine(line) {
|
|
54
|
+
let msg;
|
|
55
|
+
try {
|
|
56
|
+
msg = JSON.parse(line);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return errorResponse(null, -32700, "Parse error");
|
|
60
|
+
}
|
|
61
|
+
// JSON-RPC batching was removed from MCP: arrays are invalid requests.
|
|
62
|
+
if (typeof msg !== "object" || msg === null || Array.isArray(msg)) {
|
|
63
|
+
return errorResponse(null, -32600, "Invalid Request");
|
|
64
|
+
}
|
|
65
|
+
const record = msg;
|
|
66
|
+
const { method, params } = record;
|
|
67
|
+
// A response from the client (has result/error, no method): ignore.
|
|
68
|
+
if (method === undefined && ("result" in record || "error" in record))
|
|
69
|
+
return undefined;
|
|
70
|
+
const id = typeof record.id === "string" || typeof record.id === "number" ? record.id : null;
|
|
71
|
+
if (typeof method !== "string")
|
|
72
|
+
return errorResponse(id, -32600, "Invalid Request");
|
|
73
|
+
if (method.startsWith("notifications/"))
|
|
74
|
+
return undefined;
|
|
75
|
+
// A request without an id is a notification — never respond.
|
|
76
|
+
if (!("id" in record))
|
|
77
|
+
return undefined;
|
|
78
|
+
const p = (typeof params === "object" && params !== null && !Array.isArray(params) ? params : {});
|
|
79
|
+
try {
|
|
80
|
+
switch (method) {
|
|
81
|
+
case "initialize": {
|
|
82
|
+
const requested = typeof p.protocolVersion === "string" ? p.protocolVersion : "";
|
|
83
|
+
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.has(requested)
|
|
84
|
+
? requested
|
|
85
|
+
: LATEST_PROTOCOL_VERSION;
|
|
86
|
+
return response(id, { protocolVersion, capabilities: { tools: {} }, serverInfo });
|
|
87
|
+
}
|
|
88
|
+
case "ping":
|
|
89
|
+
return response(id, {});
|
|
90
|
+
case "tools/list":
|
|
91
|
+
return response(id, {
|
|
92
|
+
tools: tools.map(({ name, description, inputSchema }) => ({
|
|
93
|
+
name,
|
|
94
|
+
description,
|
|
95
|
+
inputSchema,
|
|
96
|
+
})),
|
|
97
|
+
});
|
|
98
|
+
case "tools/call":
|
|
99
|
+
return await callTool(id, p);
|
|
100
|
+
default:
|
|
101
|
+
return errorResponse(id, -32601, `method not found: ${method}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
return errorResponse(id, -32603, err instanceof Error ? err.message : String(err));
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Stdio transport for the MCP server core (Node-only; the core itself is runtime-agnostic). */
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { writeStdout } from "../io.js";
|
|
4
|
+
/** Serve the MCP protocol on stdin/stdout; resolves when stdin ends. */
|
|
5
|
+
export async function serveStdio(server) {
|
|
6
|
+
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
|
|
7
|
+
for await (const line of rl) {
|
|
8
|
+
if (!line.trim())
|
|
9
|
+
continue;
|
|
10
|
+
const out = await server.handleLine(line);
|
|
11
|
+
if (out !== undefined)
|
|
12
|
+
await writeStdout(out + "\n");
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { GlobalFlags } from "../cli-args.js";
|
|
2
|
+
import { type UploadsClient } from "../client.js";
|
|
3
|
+
import { type UploadsClientConfig } from "../config.js";
|
|
4
|
+
import { type CommandRunner } from "../github-gh.js";
|
|
5
|
+
import type { McpTool } from "./server.js";
|
|
6
|
+
export declare function createUploadsMcpTools(opts: {
|
|
7
|
+
globals: GlobalFlags;
|
|
8
|
+
runner?: CommandRunner;
|
|
9
|
+
clientFactory?: (config: UploadsClientConfig) => UploadsClient;
|
|
10
|
+
}): McpTool[];
|