perso-mcp-server 1.0.0 → 1.0.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/client.d.ts CHANGED
@@ -15,5 +15,10 @@ export declare class PersoApiClient {
15
15
  put<T>(path: string, body?: unknown, query?: Record<string, string>): Promise<T>;
16
16
  patch<T>(path: string, body?: unknown, query?: Record<string, string>): Promise<T>;
17
17
  delete(path: string, query?: Record<string, string>): Promise<void>;
18
+ /**
19
+ * Upload a file buffer directly to Azure Blob Storage via SAS URL.
20
+ * This bypasses the PERSO API — the SAS URL already contains authentication.
21
+ */
22
+ static uploadBlob(blobSasUrl: string, fileBuffer: Buffer): Promise<string>;
18
23
  }
19
24
  export declare function createClient(): PersoApiClient;
package/dist/client.js CHANGED
@@ -81,6 +81,30 @@ export class PersoApiClient {
81
81
  async delete(path, query) {
82
82
  return this.request("DELETE", path, undefined, query);
83
83
  }
84
+ /**
85
+ * Upload a file buffer directly to Azure Blob Storage via SAS URL.
86
+ * This bypasses the PERSO API — the SAS URL already contains authentication.
87
+ */
88
+ static async uploadBlob(blobSasUrl, fileBuffer) {
89
+ const response = await fetch(blobSasUrl, {
90
+ method: "PUT",
91
+ headers: {
92
+ "x-ms-blob-type": "BlockBlob",
93
+ "Content-Type": "application/octet-stream",
94
+ "Content-Length": String(fileBuffer.byteLength),
95
+ },
96
+ body: fileBuffer,
97
+ });
98
+ if (response.status === 403) {
99
+ throw new PersoApiError("SAS_EXPIRED", 403, "SAS URL has expired. Call get_sas_token again to obtain a new URL.");
100
+ }
101
+ if (!response.ok) {
102
+ throw new PersoApiError("BLOB_UPLOAD_FAILED", response.status, `Azure Blob upload failed with HTTP ${response.status}`);
103
+ }
104
+ // Extract blob URL (path portion before the '?' query string)
105
+ const url = new URL(blobSasUrl);
106
+ return `${url.origin}${url.pathname}`;
107
+ }
84
108
  }
85
109
  export function createClient() {
86
110
  const apiKey = process.env.PERSO_API_KEY;
@@ -1,3 +1,3 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import type { PersoApiClient } from "../client.js";
2
+ import { PersoApiClient } from "../client.js";
3
3
  export declare function registerFileTools(server: McpServer, client: PersoApiClient): void;
@@ -1,15 +1,139 @@
1
+ import { readFile } from "node:fs/promises";
1
2
  import { z } from "zod";
3
+ import { PersoApiClient } from "../client.js";
2
4
  import { errorResult, textResult } from "../utils/formatting.js";
3
5
  export function registerFileTools(server, client) {
4
- server.tool("upload_media", "Upload a video or audio file to PERSO. Supports direct file URLs and external platform URLs (YouTube, TikTok, Google Drive). Returns mediaSeq needed for creating translations.", {
5
- spaceSeq: z.number().describe("Space ID to upload to"),
6
- source: z
7
- .enum(["url", "external"])
8
- .describe("'url' for direct file URL (.mp4, .webm, .mov, .mp3, .wav). 'external' for YouTube/TikTok/Google Drive URL."),
6
+ // ──────────────────────────────────────────────
7
+ // Direct File Upload Workflow (Step 1 of 3)
8
+ // ──────────────────────────────────────────────
9
+ server.tool("get_sas_token", [
10
+ "Get a temporary Azure Blob Storage SAS URL for direct file upload.",
11
+ "",
12
+ "** Direct File Upload Workflow **",
13
+ " Step 1: get_sas_token → obtain blobSasUrl + expiresOn",
14
+ " Step 2: upload_file_to_blob → upload local file binary to blobSasUrl",
15
+ " Step 3: upload_media (source='blob') → register the blob URL with PERSO",
16
+ ].join("\n"), {
9
17
  mediaType: z
10
18
  .enum(["video", "audio"])
11
19
  .describe("Type of media to upload"),
20
+ fileName: z
21
+ .string()
22
+ .describe("File name with extension (e.g., 'my_video.mp4')"),
23
+ }, async ({ mediaType, fileName }) => {
24
+ try {
25
+ const result = await client.post("/file/api/v1/upload/sas-token", { mediaType: mediaType.toUpperCase(), fileName });
26
+ return textResult([
27
+ `SAS Token obtained`,
28
+ `Blob SAS URL: ${result.blobSasUrl}`,
29
+ `Expires: ${result.expiresOn}`,
30
+ ``,
31
+ `Next step: call upload_file_to_blob with this blobSasUrl and your local file path.`,
32
+ ].join("\n"));
33
+ }
34
+ catch (error) {
35
+ return errorResult(error);
36
+ }
37
+ });
38
+ // ──────────────────────────────────────────────
39
+ // Direct File Upload Workflow (Step 2 of 3)
40
+ // ──────────────────────────────────────────────
41
+ server.tool("upload_file_to_blob", [
42
+ "Upload a local file to Azure Blob Storage using the SAS URL from get_sas_token.",
43
+ "Returns the blob URL (without query string) to use as fileUrl in upload_media.",
44
+ "",
45
+ "** Direct File Upload Workflow **",
46
+ " Step 1: get_sas_token → obtain blobSasUrl + expiresOn",
47
+ " Step 2: upload_file_to_blob → upload local file binary to blobSasUrl ← YOU ARE HERE",
48
+ " Step 3: upload_media (source='blob') → register the blob URL with PERSO",
49
+ "",
50
+ "Note: The SAS URL contains authentication, so no API key is needed.",
51
+ "A 403 response means the SAS URL expired — call get_sas_token again.",
52
+ ].join("\n"), {
53
+ filePath: z
54
+ .string()
55
+ .describe("Absolute path to the local file to upload"),
56
+ blobSasUrl: z
57
+ .string()
58
+ .describe("The blobSasUrl obtained from get_sas_token"),
59
+ }, async ({ filePath, blobSasUrl }) => {
60
+ try {
61
+ const fileBuffer = await readFile(filePath);
62
+ const blobUrl = await PersoApiClient.uploadBlob(blobSasUrl, fileBuffer);
63
+ return textResult([
64
+ `File uploaded to blob storage successfully`,
65
+ `Blob URL: ${blobUrl}`,
66
+ `File size: ${fileBuffer.byteLength} bytes`,
67
+ ``,
68
+ `Next step: call upload_media with source='blob' and fileUrl='${blobUrl}'`,
69
+ ].join("\n"));
70
+ }
71
+ catch (error) {
72
+ return errorResult(error);
73
+ }
74
+ });
75
+ // ──────────────────────────────────────────────
76
+ // Validate Media (for both workflows)
77
+ // ──────────────────────────────────────────────
78
+ server.tool("validate_media", [
79
+ "Validate media file constraints (size, duration, resolution) before uploading.",
80
+ "Use this to check if a file or external URL meets PERSO requirements.",
81
+ "",
82
+ "For external platforms (YouTube, TikTok, Google Drive):",
83
+ " Step 1: get_external_metadata → preview media info",
84
+ " Step 2: validate_media ← YOU ARE HERE",
85
+ " Step 3: upload_media (source='external') → start import",
86
+ ].join("\n"), {
87
+ fileName: z
88
+ .string()
89
+ .describe("File name with extension (e.g., 'video.mp4')"),
12
90
  fileUrl: z.string().describe("Direct file URL or external platform URL"),
91
+ mediaType: z
92
+ .enum(["video", "audio"])
93
+ .describe("Type of media to validate"),
94
+ }, async ({ fileName, fileUrl, mediaType }) => {
95
+ try {
96
+ const result = await client.post("/file/api/v1/media/validate", {
97
+ fileName,
98
+ fileUrl,
99
+ mediaType: mediaType.toUpperCase(),
100
+ });
101
+ const lines = [`Validation passed`];
102
+ if (result.maxDurationMs)
103
+ lines.push(`Max duration: ${result.maxDurationMs}ms`);
104
+ if (result.maxSizeBytes)
105
+ lines.push(`Max size: ${result.maxSizeBytes} bytes`);
106
+ if (result.maxResolution)
107
+ lines.push(`Max resolution: ${result.maxResolution}`);
108
+ if (result.message)
109
+ lines.push(`Message: ${result.message}`);
110
+ return textResult(lines.join("\n"));
111
+ }
112
+ catch (error) {
113
+ return errorResult(error);
114
+ }
115
+ });
116
+ // ──────────────────────────────────────────────
117
+ // Upload Media — final registration step
118
+ // ──────────────────────────────────────────────
119
+ server.tool("upload_media", [
120
+ "Register an uploaded file with PERSO. Returns mediaSeq for creating translations.",
121
+ "",
122
+ "source='blob' — Use after uploading a local file via get_sas_token + upload_file_to_blob.",
123
+ " Provide the blob URL (returned by upload_file_to_blob) as fileUrl.",
124
+ "source='external' — Use for YouTube / TikTok / Google Drive URLs.",
125
+ " Optionally call get_external_metadata and validate_media first.",
126
+ ].join("\n"), {
127
+ spaceSeq: z.number().describe("Space ID to upload to"),
128
+ source: z
129
+ .enum(["blob", "external"])
130
+ .describe("'blob' for files uploaded via SAS URL. 'external' for YouTube/TikTok/Google Drive URL."),
131
+ mediaType: z
132
+ .enum(["video", "audio"])
133
+ .describe("Type of media to register"),
134
+ fileUrl: z
135
+ .string()
136
+ .describe("Blob URL (from upload_file_to_blob) or external platform URL"),
13
137
  fileName: z
14
138
  .string()
15
139
  .describe("File name with extension (e.g., 'my_video.mp4')"),
@@ -19,17 +143,14 @@ export function registerFileTools(server, client) {
19
143
  const result = await client.put("/file/api/upload/video/external", { spaceSeq, fileUrl, fileName });
20
144
  return textResult([
21
145
  `Upload successful (external)`,
22
- `Media ID: ${result.seq}`,
146
+ `Media ID (mediaSeq): ${result.seq}`,
23
147
  `Duration: ${result.durationMs}ms`,
24
148
  `Size: ${result.size} bytes`,
149
+ ``,
150
+ `Use mediaSeq=${result.seq} when creating a translation via create_translation.`,
25
151
  ].join("\n"));
26
152
  }
27
- // Direct URL upload - validate first, then upload
28
- await client.post("/file/api/v1/media/validate", {
29
- fileName,
30
- fileUrl,
31
- mediaType: mediaType.toUpperCase(),
32
- });
153
+ // Blob upload register the blob URL with PERSO
33
154
  const endpoint = mediaType === "video"
34
155
  ? "/file/api/upload/video"
35
156
  : "/file/api/upload/audio";
@@ -41,17 +162,30 @@ export function registerFileTools(server, client) {
41
162
  const filePath = result.videoFilePath || result.audioFilePath || "";
42
163
  return textResult([
43
164
  `Upload successful`,
44
- `Media ID: ${result.seq}`,
165
+ `Media ID (mediaSeq): ${result.seq}`,
45
166
  `File Path: ${filePath}`,
46
167
  `Duration: ${result.durationMs}ms`,
47
168
  `Size: ${result.size} bytes`,
169
+ ``,
170
+ `Use mediaSeq=${result.seq} when creating a translation via create_translation.`,
48
171
  ].join("\n"));
49
172
  }
50
173
  catch (error) {
51
174
  return errorResult(error);
52
175
  }
53
176
  });
54
- server.tool("get_external_metadata", "Preview metadata of an external video (YouTube, TikTok, Google Drive) without downloading. Returns title, duration, and thumbnail.", {
177
+ // ──────────────────────────────────────────────
178
+ // External Metadata — preview external media
179
+ // ──────────────────────────────────────────────
180
+ server.tool("get_external_metadata", [
181
+ "Preview metadata of an external video (YouTube, TikTok, Google Drive) without downloading.",
182
+ "Returns title, duration, and thumbnail. Use before validate_media and upload_media.",
183
+ "",
184
+ "** External Platform Upload Workflow **",
185
+ " Step 1: get_external_metadata ← YOU ARE HERE",
186
+ " Step 2: validate_media → check constraints",
187
+ " Step 3: upload_media (source='external') → start import",
188
+ ].join("\n"), {
55
189
  url: z.string().describe("YouTube, TikTok, or Google Drive URL"),
56
190
  }, async ({ url }) => {
57
191
  try {
package/dist/types.d.ts CHANGED
@@ -18,6 +18,17 @@ export interface ExternalMetadata {
18
18
  durationMs: number;
19
19
  thumbnailUrl: string;
20
20
  }
21
+ export interface SasTokenResult {
22
+ blobSasUrl: string;
23
+ expiresOn: string;
24
+ }
25
+ export interface MediaValidationResult {
26
+ valid: boolean;
27
+ maxDurationMs?: number;
28
+ maxSizeBytes?: number;
29
+ maxResolution?: string;
30
+ message?: string;
31
+ }
21
32
  export interface Project {
22
33
  projectSeq: number;
23
34
  title: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perso-mcp-server",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "MCP server for PERSO API - video translation and dubbing platform",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",