relay-companion 0.1.13 → 0.1.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Relay companion: connects local coding agents to Relay tasks, approvals, and connector tools.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,8 @@
15
15
  "dependencies": {
16
16
  "@anthropic-ai/claude-agent-sdk": "^0.3.195",
17
17
  "@modelcontextprotocol/sdk": "^1.0.4",
18
- "electron": "^32.0.0"
18
+ "electron": "^32.0.0",
19
+ "ws": "^8.21.0"
19
20
  },
20
21
  "files": [
21
22
  "bin",
@@ -0,0 +1,161 @@
1
+ import { createHash } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ const CONTENT_TYPES = new Map([
6
+ [".csv", "text/csv"],
7
+ [".doc", "application/msword"],
8
+ [".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
9
+ [".gif", "image/gif"],
10
+ [".htm", "text/html"],
11
+ [".html", "text/html"],
12
+ [".jpeg", "image/jpeg"],
13
+ [".jpg", "image/jpeg"],
14
+ [".json", "application/json"],
15
+ [".md", "text/markdown"],
16
+ [".pdf", "application/pdf"],
17
+ [".png", "image/png"],
18
+ [".ppt", "application/vnd.ms-powerpoint"],
19
+ [".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"],
20
+ [".rtf", "application/rtf"],
21
+ [".tex", "application/x-tex"],
22
+ [".text", "text/plain"],
23
+ [".txt", "text/plain"],
24
+ [".webp", "image/webp"],
25
+ [".xls", "application/vnd.ms-excel"],
26
+ [".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
27
+ [".zip", "application/zip"],
28
+ ]);
29
+
30
+ export async function prepareOrdinaryRelayAttachments(args = {}) {
31
+ const passthrough = passthroughAttachments(args);
32
+ const local = await readLocalAttachmentSpecs(localAttachmentSpecs(args));
33
+ return [
34
+ ...passthrough,
35
+ ...local.map((file, index) => ({
36
+ id: file.id || ordinaryAttachmentId(file, index),
37
+ name: file.name,
38
+ contentType: file.contentType,
39
+ bytes: file.bytes,
40
+ sha256: file.sha256,
41
+ contentBase64: file.body.toString("base64"),
42
+ })),
43
+ ];
44
+ }
45
+
46
+ export async function prepareTaskMessageAttachments(client, taskId, args = {}) {
47
+ const passthrough = passthroughAttachments(args);
48
+ const local = await readLocalAttachmentSpecs(localAttachmentSpecs(args));
49
+ const uploaded = await uploadLocalTaskFiles(client, taskId, local, args.idempotencyKey || "relay_mcp_task_file");
50
+ return [...passthrough, ...uploaded];
51
+ }
52
+
53
+ export async function prepareTaskResult(client, taskId, result = {}, baseIdempotencyKey = "relay_mcp_result_file") {
54
+ return {
55
+ ...result,
56
+ attachments: await prepareTaskMessageAttachments(client, taskId, {
57
+ attachments: result.attachments || [],
58
+ files: result.files || [],
59
+ idempotencyKey: baseIdempotencyKey,
60
+ }),
61
+ };
62
+ }
63
+
64
+ function localAttachmentSpecs(args = {}) {
65
+ const specs = [];
66
+ for (const item of arrayOf(args.files)) {
67
+ if (typeof item === "string") specs.push({ path: item });
68
+ else if (item && typeof item === "object") specs.push({ ...item, path: item.path || item.filePath });
69
+ }
70
+ for (const item of arrayOf(args.attachments)) {
71
+ if (item && typeof item === "object" && (item.path || item.filePath)) {
72
+ specs.push({ ...item, path: item.path || item.filePath });
73
+ }
74
+ }
75
+ return specs.filter((spec) => typeof spec.path === "string" && spec.path.trim().length > 0);
76
+ }
77
+
78
+ function passthroughAttachments(args = {}) {
79
+ return arrayOf(args.attachments).filter((item) => !(item && typeof item === "object" && (item.path || item.filePath)));
80
+ }
81
+
82
+ async function readLocalAttachmentSpecs(specs) {
83
+ const out = [];
84
+ for (const [index, spec] of specs.entries()) {
85
+ const filePath = path.resolve(spec.path);
86
+ const stat = await fs.stat(filePath);
87
+ if (!stat.isFile()) throw new Error(`Attachment path is not a file: ${filePath}`);
88
+ const body = await fs.readFile(filePath);
89
+ const name = String(spec.name || spec.filename || path.basename(filePath)).trim() || `attachment-${index + 1}`;
90
+ const sha256 = createHash("sha256").update(body).digest("hex");
91
+ out.push({
92
+ id: spec.id,
93
+ path: filePath,
94
+ name,
95
+ filename: name,
96
+ contentType: spec.contentType || inferContentType(name),
97
+ bytes: body.length,
98
+ sha256,
99
+ body,
100
+ });
101
+ }
102
+ return out;
103
+ }
104
+
105
+ async function uploadLocalTaskFiles(client, taskId, files, baseIdempotencyKey) {
106
+ const uploaded = [];
107
+ for (const [index, file] of files.entries()) {
108
+ const result = await client.createFileUpload({
109
+ taskId,
110
+ filename: file.name,
111
+ contentType: file.contentType,
112
+ sizeBytes: file.bytes,
113
+ sha256: file.sha256,
114
+ metadataScanStatus: "pending",
115
+ idempotencyKey: fileUploadIdempotencyKey(baseIdempotencyKey, file, index),
116
+ });
117
+ await uploadBytes(result.upload, file);
118
+ uploaded.push(result.attachment);
119
+ }
120
+ return uploaded;
121
+ }
122
+
123
+ async function uploadBytes(upload, file) {
124
+ if (!upload?.url) throw new Error(`Relay did not return an upload URL for ${file.name}.`);
125
+ const method = upload.method || "PUT";
126
+ let res;
127
+ if (method === "POST") {
128
+ const form = new FormData();
129
+ for (const [key, value] of Object.entries(upload.fields || {})) form.append(key, value);
130
+ form.append("file", new Blob([file.body], { type: file.contentType }), file.name);
131
+ res = await fetch(upload.url, { method: "POST", body: form });
132
+ } else {
133
+ res = await fetch(upload.url, {
134
+ method: "PUT",
135
+ headers: { "Content-Type": file.contentType },
136
+ body: file.body,
137
+ });
138
+ }
139
+ if (!res.ok) {
140
+ const body = await res.text().catch(() => "");
141
+ const detail = body ? `: ${body.slice(0, 500)}` : "";
142
+ throw new Error(`Relay upload failed for ${file.name} with HTTP ${res.status}${detail}`);
143
+ }
144
+ }
145
+
146
+ function fileUploadIdempotencyKey(base, file, index) {
147
+ const safeBase = String(base || "relay_mcp_file").replace(/[^\w.-]+/g, "_").slice(0, 80);
148
+ return `${safeBase}_file_${index}_${file.sha256.slice(0, 16)}`;
149
+ }
150
+
151
+ function ordinaryAttachmentId(file, index) {
152
+ return `att_${file.sha256.slice(0, 16)}_${index}`;
153
+ }
154
+
155
+ function inferContentType(filename) {
156
+ return CONTENT_TYPES.get(path.extname(filename).toLowerCase()) || "application/octet-stream";
157
+ }
158
+
159
+ function arrayOf(value) {
160
+ return Array.isArray(value) ? value : [];
161
+ }
package/src/mcp.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
4
+ import { prepareOrdinaryRelayAttachments, prepareTaskMessageAttachments, prepareTaskResult } from "./attachments.js";
4
5
  import { RelayClient } from "./client.js";
5
6
 
6
7
  export const TOOLS = [
@@ -32,12 +33,15 @@ export const TOOLS = [
32
33
  attachments: {
33
34
  type: "array",
34
35
  description:
35
- "Optional files to send with the relay. Each item must include id, name, contentType, bytes, and optionally sha256. If the recipient may not be on Relay and will receive email fallback, include contentBase64 with the exact file bytes so Relay can attach the files directly to the email; otherwise the API will reject the send instead of emailing a broken partial relay.",
36
+ "Optional files to send with the relay. Prefer files: [absolutePath] for local files. Low-level callers may pass fully prepared attachments with id, name, contentType, bytes, sha256, and contentBase64.",
36
37
  items: {
37
38
  type: "object",
38
39
  properties: {
40
+ path: { type: "string", description: "Absolute local file path. Relay will read, hash, and attach the file." },
41
+ filePath: { type: "string", description: "Alias for path." },
39
42
  id: { type: "string" },
40
43
  name: { type: "string" },
44
+ filename: { type: "string", description: "Optional display filename for path attachments." },
41
45
  contentType: { type: "string" },
42
46
  bytes: { type: "number" },
43
47
  sha256: { type: "string" },
@@ -45,6 +49,11 @@ export const TOOLS = [
45
49
  },
46
50
  },
47
51
  },
52
+ files: {
53
+ type: "array",
54
+ description: "Absolute local file paths to attach. Relay reads, hashes, and includes the bytes safely.",
55
+ items: { type: "string" },
56
+ },
48
57
  idempotencyKey: { type: "string" },
49
58
  },
50
59
  required: ["recipient", "title", "bodyMarkdown", "idempotencyKey"],
@@ -151,7 +160,25 @@ export const TOOLS = [
151
160
  recipientParticipantIds: { type: "array", items: { type: "string" } },
152
161
  bodyMarkdown: { type: "string" },
153
162
  shareSummary: { type: "string" },
154
- attachments: { type: "array", items: { type: "object" } },
163
+ attachments: {
164
+ type: "array",
165
+ description: "Prefer files: [absolutePath] for local files. Existing Relay file attachment objects still work.",
166
+ items: {
167
+ type: "object",
168
+ properties: {
169
+ path: { type: "string", description: "Absolute local file path. Relay will upload and attach it." },
170
+ filePath: { type: "string", description: "Alias for path." },
171
+ filename: { type: "string" },
172
+ name: { type: "string" },
173
+ contentType: { type: "string" },
174
+ },
175
+ },
176
+ },
177
+ files: {
178
+ type: "array",
179
+ description: "Absolute local file paths to upload and attach to this task message.",
180
+ items: { type: "string" },
181
+ },
155
182
  provenance: { type: "array", items: { type: "object" } },
156
183
  senderAgentSessionId: { type: "string" },
157
184
  idempotencyKey: { type: "string" },
@@ -171,7 +198,25 @@ export const TOOLS = [
171
198
  recipientUserIds: { type: "array", items: { type: "string" } },
172
199
  bodyMarkdown: { type: "string" },
173
200
  shareSummary: { type: "string" },
174
- attachments: { type: "array", items: { type: "object" } },
201
+ attachments: {
202
+ type: "array",
203
+ description: "Prefer files: [absolutePath] for local files. Existing Relay file attachment objects still work.",
204
+ items: {
205
+ type: "object",
206
+ properties: {
207
+ path: { type: "string", description: "Absolute local file path. Relay will upload and attach it." },
208
+ filePath: { type: "string", description: "Alias for path." },
209
+ filename: { type: "string" },
210
+ name: { type: "string" },
211
+ contentType: { type: "string" },
212
+ },
213
+ },
214
+ },
215
+ files: {
216
+ type: "array",
217
+ description: "Absolute local file paths to upload and attach to this task message.",
218
+ items: { type: "string" },
219
+ },
175
220
  provenance: { type: "array", items: { type: "object" } },
176
221
  senderAgentSessionId: { type: "string" },
177
222
  humanResponse: {
@@ -256,6 +301,12 @@ export const TOOLS = [
256
301
  status: { type: "string", enum: ["success", "partial_success", "failed", "cancelled"] },
257
302
  title: { type: "string" },
258
303
  bodyMarkdown: { type: "string" },
304
+ files: {
305
+ type: "array",
306
+ description: "Absolute local file paths to upload and attach to the creator-scoped result.",
307
+ items: { type: "string" },
308
+ },
309
+ attachments: { type: "array", description: "Existing Relay file attachment objects or path attachments.", items: { type: "object" } },
259
310
  sourceApprovalIds: { type: "array", items: { type: "string" } },
260
311
  sourceResultIds: { type: "array", items: { type: "string" } },
261
312
  },
@@ -270,6 +321,12 @@ export const TOOLS = [
270
321
  status: { type: "string", enum: ["success", "partial_success", "failed", "cancelled"] },
271
322
  title: { type: "string" },
272
323
  bodyMarkdown: { type: "string" },
324
+ files: {
325
+ type: "array",
326
+ description: "Absolute local file paths to upload and attach to this participant-scoped result.",
327
+ items: { type: "string" },
328
+ },
329
+ attachments: { type: "array", description: "Existing Relay file attachment objects or path attachments.", items: { type: "object" } },
273
330
  sourceApprovalIds: { type: "array", items: { type: "string" } },
274
331
  sourceResultIds: { type: "array", items: { type: "string" } },
275
332
  },
@@ -379,7 +436,7 @@ export async function handleCall(client, name, args) {
379
436
  userInstructions: args.userInstructions || "",
380
437
  source: { host: "relay-mcp" },
381
438
  targetSurfaces: args.targetSurfaces || [],
382
- attachments: args.attachments || [],
439
+ attachments: await prepareOrdinaryRelayAttachments(args),
383
440
  idempotencyKey: args.idempotencyKey,
384
441
  })),
385
442
  );
@@ -423,7 +480,7 @@ export async function handleCall(client, name, args) {
423
480
  recipientParticipantIds: args.recipientParticipantIds || [],
424
481
  shareSummary: args.shareSummary,
425
482
  destinationKind: "agent",
426
- attachments: args.attachments || [],
483
+ attachments: await prepareTaskMessageAttachments(client, args.taskId, args),
427
484
  provenance: args.provenance || [],
428
485
  senderAgentSessionId: args.senderAgentSessionId,
429
486
  idempotencyKey: args.idempotencyKey,
@@ -438,7 +495,7 @@ export async function handleCall(client, name, args) {
438
495
  recipientUserIds: args.recipientUserIds || [],
439
496
  shareSummary: args.shareSummary,
440
497
  destinationKind: "human",
441
- attachments: args.attachments || [],
498
+ attachments: await prepareTaskMessageAttachments(client, args.taskId, args),
442
499
  provenance: args.provenance || [],
443
500
  humanResponse: args.humanResponse,
444
501
  senderAgentSessionId: args.senderAgentSessionId,
@@ -477,8 +534,12 @@ export async function handleCall(client, name, args) {
477
534
  case "relay_end_task":
478
535
  return text(
479
536
  await client.completeTask(args.taskId, {
480
- creatorResult: args.creatorResult,
481
- participantResults: args.participantResults || [],
537
+ creatorResult: await prepareTaskResult(client, args.taskId, args.creatorResult, `${args.idempotencyKey}_creator`),
538
+ participantResults: await Promise.all(
539
+ (args.participantResults || []).map((result, index) =>
540
+ prepareTaskResult(client, args.taskId, result, `${args.idempotencyKey}_participant_${index}`),
541
+ ),
542
+ ),
482
543
  senderAgentSessionId: args.senderAgentSessionId,
483
544
  expectedVersion: args.expectedVersion,
484
545
  idempotencyKey: args.idempotencyKey,