perso-mcp-server 1.0.0 → 1.0.2

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;
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { createClient } from "./client.js";
5
+ import { SpaceResolver } from "./utils/space-resolver.js";
5
6
  import { registerSpaceTools } from "./tools/spaces.js";
6
7
  import { registerFileTools } from "./tools/files.js";
7
8
  import { registerProjectTools } from "./tools/projects.js";
@@ -13,18 +14,19 @@ import { registerLipSyncTools } from "./tools/lip-sync.js";
13
14
  import { registerFeedbackTools } from "./tools/feedback.js";
14
15
  async function main() {
15
16
  const client = createClient();
17
+ const resolver = new SpaceResolver(client);
16
18
  const server = new McpServer({
17
19
  name: "perso-mcp-server",
18
- version: "1.0.0",
20
+ version: "1.0.1",
19
21
  });
20
- registerSpaceTools(server, client);
21
- registerFileTools(server, client);
22
- registerProjectTools(server, client);
23
- registerScriptTools(server, client);
24
- registerUsageTools(server, client);
22
+ registerSpaceTools(server, client, resolver);
23
+ registerFileTools(server, client, resolver);
24
+ registerProjectTools(server, client, resolver);
25
+ registerScriptTools(server, client, resolver);
26
+ registerUsageTools(server, client, resolver);
25
27
  registerLanguageTools(server, client);
26
28
  registerCommunityTools(server, client);
27
- registerLipSyncTools(server, client);
29
+ registerLipSyncTools(server, client, resolver);
28
30
  registerFeedbackTools(server, client);
29
31
  const transport = new StdioServerTransport();
30
32
  await server.connect(transport);
@@ -1,3 +1,4 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import type { PersoApiClient } from "../client.js";
3
- export declare function registerFileTools(server: McpServer, client: PersoApiClient): void;
2
+ import { PersoApiClient } from "../client.js";
3
+ import type { SpaceResolver } from "../utils/space-resolver.js";
4
+ export declare function registerFileTools(server: McpServer, client: PersoApiClient, resolver: SpaceResolver): void;
@@ -1,57 +1,196 @@
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
- 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."),
5
+ export function registerFileTools(server, client, resolver) {
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
+ "spaceSeq is optional — omit to use the default space.",
122
+ "",
123
+ "source='blob' — Use after uploading a local file via get_sas_token + upload_file_to_blob.",
124
+ " Provide the blob URL (returned by upload_file_to_blob) as fileUrl.",
125
+ "source='external' — Use for YouTube / TikTok / Google Drive URLs.",
126
+ " Optionally call get_external_metadata and validate_media first.",
127
+ ].join("\n"), {
128
+ spaceSeq: z
129
+ .number()
130
+ .optional()
131
+ .describe("Space ID (omit to use default space)"),
132
+ source: z
133
+ .enum(["blob", "external"])
134
+ .describe("'blob' for files uploaded via SAS URL. 'external' for YouTube/TikTok/Google Drive URL."),
135
+ mediaType: z
136
+ .enum(["video", "audio"])
137
+ .describe("Type of media to register"),
138
+ fileUrl: z
139
+ .string()
140
+ .describe("Blob URL (from upload_file_to_blob) or external platform URL"),
13
141
  fileName: z
14
142
  .string()
15
143
  .describe("File name with extension (e.g., 'my_video.mp4')"),
16
144
  }, async ({ spaceSeq, source, mediaType, fileUrl, fileName }) => {
17
145
  try {
146
+ const resolvedSpaceSeq = await resolver.resolve(spaceSeq);
18
147
  if (source === "external") {
19
- const result = await client.put("/file/api/upload/video/external", { spaceSeq, fileUrl, fileName });
148
+ const result = await client.put("/file/api/upload/video/external", { spaceSeq: resolvedSpaceSeq, fileUrl, fileName });
20
149
  return textResult([
21
150
  `Upload successful (external)`,
22
- `Media ID: ${result.seq}`,
151
+ `Media ID (mediaSeq): ${result.seq}`,
23
152
  `Duration: ${result.durationMs}ms`,
24
153
  `Size: ${result.size} bytes`,
154
+ ``,
155
+ `Use mediaSeq=${result.seq} when creating a translation via create_translation.`,
25
156
  ].join("\n"));
26
157
  }
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
- });
158
+ // Blob upload register the blob URL with PERSO
33
159
  const endpoint = mediaType === "video"
34
160
  ? "/file/api/upload/video"
35
161
  : "/file/api/upload/audio";
36
162
  const result = await client.put(endpoint, {
37
- spaceSeq,
163
+ spaceSeq: resolvedSpaceSeq,
38
164
  fileUrl,
39
165
  fileName,
40
166
  });
41
167
  const filePath = result.videoFilePath || result.audioFilePath || "";
42
168
  return textResult([
43
169
  `Upload successful`,
44
- `Media ID: ${result.seq}`,
170
+ `Media ID (mediaSeq): ${result.seq}`,
45
171
  `File Path: ${filePath}`,
46
172
  `Duration: ${result.durationMs}ms`,
47
173
  `Size: ${result.size} bytes`,
174
+ ``,
175
+ `Use mediaSeq=${result.seq} when creating a translation via create_translation.`,
48
176
  ].join("\n"));
49
177
  }
50
178
  catch (error) {
51
179
  return errorResult(error);
52
180
  }
53
181
  });
54
- server.tool("get_external_metadata", "Preview metadata of an external video (YouTube, TikTok, Google Drive) without downloading. Returns title, duration, and thumbnail.", {
182
+ // ──────────────────────────────────────────────
183
+ // External Metadata — preview external media
184
+ // ──────────────────────────────────────────────
185
+ server.tool("get_external_metadata", [
186
+ "Preview metadata of an external video (YouTube, TikTok, Google Drive) without downloading.",
187
+ "Returns title, duration, and thumbnail. Use before validate_media and upload_media.",
188
+ "",
189
+ "** External Platform Upload Workflow **",
190
+ " Step 1: get_external_metadata ← YOU ARE HERE",
191
+ " Step 2: validate_media → check constraints",
192
+ " Step 3: upload_media (source='external') → start import",
193
+ ].join("\n"), {
55
194
  url: z.string().describe("YouTube, TikTok, or Google Drive URL"),
56
195
  }, async ({ url }) => {
57
196
  try {
@@ -1,3 +1,4 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { PersoApiClient } from "../client.js";
3
- export declare function registerLipSyncTools(server: McpServer, client: PersoApiClient): void;
3
+ import type { SpaceResolver } from "../utils/space-resolver.js";
4
+ export declare function registerLipSyncTools(server: McpServer, client: PersoApiClient, resolver: SpaceResolver): void;
@@ -1,10 +1,13 @@
1
1
  import { z } from "zod";
2
2
  import { errorResult, textResult } from "../utils/formatting.js";
3
- export function registerLipSyncTools(server, client) {
3
+ export function registerLipSyncTools(server, client, resolver) {
4
4
  // Tool: request_lip_sync
5
- server.tool("request_lip_sync", "Request lip sync generation for a PERSO translation project. Creates a lip-synced version of the translated video.", {
5
+ server.tool("request_lip_sync", "Request lip sync generation for a PERSO translation project. Creates a lip-synced version of the translated video. spaceSeq is optional — omit to use the default space.", {
6
6
  projectSeq: z.number().describe("Project ID"),
7
- spaceSeq: z.number().describe("Space ID"),
7
+ spaceSeq: z
8
+ .number()
9
+ .optional()
10
+ .describe("Space ID (omit to use default space)"),
8
11
  preferredSpeedType: z
9
12
  .enum(["GREEN", "RED"])
10
13
  .optional()
@@ -12,7 +15,8 @@ export function registerLipSyncTools(server, client) {
12
15
  .describe("GREEN = standard speed, RED = expedited"),
13
16
  }, async ({ projectSeq, spaceSeq, preferredSpeedType }) => {
14
17
  try {
15
- const result = await client.post(`/video-translator/api/v1/projects/${projectSeq}/spaces/${spaceSeq}/lip-sync`, { preferredSpeedType });
18
+ const resolvedSpaceSeq = await resolver.resolve(spaceSeq);
19
+ const result = await client.post(`/video-translator/api/v1/projects/${projectSeq}/spaces/${resolvedSpaceSeq}/lip-sync`, { preferredSpeedType });
16
20
  return textResult([
17
21
  `Lip sync requested successfully.`,
18
22
  `Generation IDs: ${result.generationIds?.join(", ") || "N/A"}`,
@@ -24,14 +28,18 @@ export function registerLipSyncTools(server, client) {
24
28
  }
25
29
  });
26
30
  // Tool: get_lip_sync_history
27
- server.tool("get_lip_sync_history", "Get the lip sync generation history for a PERSO project. Shows past VIEW and DOWNLOAD entries with status.", {
31
+ server.tool("get_lip_sync_history", "Get the lip sync generation history for a PERSO project. Shows past VIEW and DOWNLOAD entries with status. spaceSeq is optional — omit to use the default space.", {
28
32
  projectSeq: z.number().describe("Project ID"),
29
- spaceSeq: z.number().describe("Space ID"),
33
+ spaceSeq: z
34
+ .number()
35
+ .optional()
36
+ .describe("Space ID (omit to use default space)"),
30
37
  page: z.number().optional().default(0).describe("Page number"),
31
38
  pageSize: z.number().optional().default(10).describe("Page size"),
32
39
  }, async ({ projectSeq, spaceSeq, page, pageSize }) => {
33
40
  try {
34
- const result = await client.get(`/video-translator/api/v1/projects/${projectSeq}/spaces/${spaceSeq}/lip-sync/generated`, { page: String(page), pageSize: String(pageSize) });
41
+ const resolvedSpaceSeq = await resolver.resolve(spaceSeq);
42
+ const result = await client.get(`/video-translator/api/v1/projects/${projectSeq}/spaces/${resolvedSpaceSeq}/lip-sync/generated`, { page: String(page), pageSize: String(pageSize) });
35
43
  if (result.content.length === 0) {
36
44
  return textResult("No lip sync history found.");
37
45
  }
@@ -1,3 +1,4 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { PersoApiClient } from "../client.js";
3
- export declare function registerProjectTools(server: McpServer, client: PersoApiClient): void;
3
+ import type { SpaceResolver } from "../utils/space-resolver.js";
4
+ export declare function registerProjectTools(server: McpServer, client: PersoApiClient, resolver: SpaceResolver): void;
@@ -10,10 +10,13 @@ function formatProject(project) {
10
10
  `Updated: ${project.updatedAt}`,
11
11
  ].join("\n");
12
12
  }
13
- export function registerProjectTools(server, client) {
13
+ export function registerProjectTools(server, client, resolver) {
14
14
  // Tool: create_translation
15
- server.tool("create_translation", "Create a new PERSO translation project. Upload media first using upload_media to get the mediaSeq. Returns project IDs for tracking.", {
16
- spaceSeq: z.number().describe("Space ID"),
15
+ server.tool("create_translation", "Create a new PERSO translation project. Upload media first using upload_media to get the mediaSeq. Returns project IDs for tracking. spaceSeq is optional — omit to use the default space.", {
16
+ spaceSeq: z
17
+ .number()
18
+ .optional()
19
+ .describe("Space ID (omit to use default space)"),
17
20
  mediaSeq: z.number().describe("Media sequence ID from upload_media"),
18
21
  isVideoProject: z
19
22
  .boolean()
@@ -40,8 +43,9 @@ export function registerProjectTools(server, client) {
40
43
  .describe("GREEN = standard speed, RED = expedited"),
41
44
  }, async ({ spaceSeq, mediaSeq, isVideoProject, sourceLanguageCode, targetLanguageCodes, numberOfSpeakers, withLipSync, preferredSpeedType, }) => {
42
45
  try {
46
+ const resolvedSpaceSeq = await resolver.resolve(spaceSeq);
43
47
  // Initialize queue first (idempotent)
44
- await client.put(`/video-translator/api/v1/projects/spaces/${spaceSeq}/queue`);
48
+ await client.put(`/video-translator/api/v1/projects/spaces/${resolvedSpaceSeq}/queue`);
45
49
  const body = {
46
50
  mediaSeq,
47
51
  isVideoProject,
@@ -53,7 +57,7 @@ export function registerProjectTools(server, client) {
53
57
  body.numberOfSpeakers = numberOfSpeakers;
54
58
  if (withLipSync)
55
59
  body.withLipSync = withLipSync;
56
- const result = await client.post(`/video-translator/api/v1/projects/spaces/${spaceSeq}/translate`, body);
60
+ const result = await client.post(`/video-translator/api/v1/projects/spaces/${resolvedSpaceSeq}/translate`, body);
57
61
  const ids = result.startGenerateProjectIdList;
58
62
  return textResult([
59
63
  `Translation project(s) created successfully!`,
@@ -66,8 +70,11 @@ export function registerProjectTools(server, client) {
66
70
  }
67
71
  });
68
72
  // Tool: list_projects
69
- server.tool("list_projects", "List PERSO translation projects in a space. Supports pagination, sorting, and filtering by type.", {
70
- spaceSeq: z.number().describe("Space ID"),
73
+ server.tool("list_projects", "List PERSO translation projects in a space. Supports pagination, sorting, and filtering by type. spaceSeq is optional — omit to use the default space.", {
74
+ spaceSeq: z
75
+ .number()
76
+ .optional()
77
+ .describe("Space ID (omit to use default space)"),
71
78
  offset: z.number().optional().default(0).describe("Page offset"),
72
79
  size: z.number().optional().default(20).describe("Page size (max 100)"),
73
80
  sort: z
@@ -81,6 +88,7 @@ export function registerProjectTools(server, client) {
81
88
  .describe("Filter by project type"),
82
89
  }, async ({ spaceSeq, offset, size, sort, type }) => {
83
90
  try {
91
+ const resolvedSpaceSeq = await resolver.resolve(spaceSeq);
84
92
  const query = {
85
93
  offset: String(offset),
86
94
  size: String(size),
@@ -88,7 +96,7 @@ export function registerProjectTools(server, client) {
88
96
  };
89
97
  if (type)
90
98
  query.type = type;
91
- const result = await client.get(`/video-translator/api/v1/projects/spaces/${spaceSeq}`, query);
99
+ const result = await client.get(`/video-translator/api/v1/projects/spaces/${resolvedSpaceSeq}`, query);
92
100
  if (result.content.length === 0) {
93
101
  return textResult("No projects found.");
94
102
  }
@@ -101,9 +109,12 @@ export function registerProjectTools(server, client) {
101
109
  }
102
110
  });
103
111
  // Tool: get_project
104
- server.tool("get_project", "Get detailed information about a PERSO project. Use the 'include' parameter to fetch multiple types of info in one call.", {
112
+ server.tool("get_project", "Get detailed information about a PERSO project. Use the 'include' parameter to fetch multiple types of info in one call. spaceSeq is optional — omit to use the default space.", {
105
113
  projectSeq: z.number().describe("Project ID"),
106
- spaceSeq: z.number().describe("Space ID"),
114
+ spaceSeq: z
115
+ .number()
116
+ .optional()
117
+ .describe("Space ID (omit to use default space)"),
107
118
  include: z
108
119
  .array(z.enum([
109
120
  "details",
@@ -118,8 +129,9 @@ export function registerProjectTools(server, client) {
118
129
  .describe("What info to include: details, progress, video_info, download_info, used_features, retranslation_status"),
119
130
  }, async ({ projectSeq, spaceSeq, include }) => {
120
131
  try {
121
- const basePath = `/video-translator/api/v1/projects/${projectSeq}/spaces/${spaceSeq}`;
122
- const basePathAlt = `/video-translator/api/v1/projects/${projectSeq}/space/${spaceSeq}`;
132
+ const resolvedSpaceSeq = await resolver.resolve(spaceSeq);
133
+ const basePath = `/video-translator/api/v1/projects/${projectSeq}/spaces/${resolvedSpaceSeq}`;
134
+ const basePathAlt = `/video-translator/api/v1/projects/${projectSeq}/space/${resolvedSpaceSeq}`;
123
135
  const sections = [];
124
136
  const fetchers = {
125
137
  details: async () => {
@@ -188,9 +200,12 @@ export function registerProjectTools(server, client) {
188
200
  }
189
201
  });
190
202
  // Tool: manage_project
191
- server.tool("manage_project", "Manage a PERSO project: update title, update access permissions, delete, or cancel translation.", {
203
+ server.tool("manage_project", "Manage a PERSO project: update title, update access permissions, delete, or cancel translation. spaceSeq is optional — omit to use the default space.", {
192
204
  projectSeq: z.number().describe("Project ID"),
193
- spaceSeq: z.number().describe("Space ID"),
205
+ spaceSeq: z
206
+ .number()
207
+ .optional()
208
+ .describe("Space ID (omit to use default space)"),
194
209
  action: z
195
210
  .enum(["update_title", "update_access", "delete", "cancel"])
196
211
  .describe("Action to perform"),
@@ -204,7 +219,8 @@ export function registerProjectTools(server, client) {
204
219
  .describe("Access level (required for update_access)"),
205
220
  }, async ({ projectSeq, spaceSeq, action, title, accessPermission }) => {
206
221
  try {
207
- const basePath = `/video-translator/api/v1/projects/${projectSeq}/spaces/${spaceSeq}`;
222
+ const resolvedSpaceSeq = await resolver.resolve(spaceSeq);
223
+ const basePath = `/video-translator/api/v1/projects/${projectSeq}/spaces/${resolvedSpaceSeq}`;
208
224
  switch (action) {
209
225
  case "update_title": {
210
226
  if (!title) {
@@ -237,9 +253,12 @@ export function registerProjectTools(server, client) {
237
253
  }
238
254
  });
239
255
  // Tool: download_project
240
- server.tool("download_project", "Get download links for a PERSO project's translated files (video, audio, SRT) or view export history.", {
256
+ server.tool("download_project", "Get download links for a PERSO project's translated files (video, audio, SRT) or view export history. spaceSeq is optional — omit to use the default space.", {
241
257
  projectSeq: z.number().describe("Project ID"),
242
- spaceSeq: z.number().describe("Space ID"),
258
+ spaceSeq: z
259
+ .number()
260
+ .optional()
261
+ .describe("Space ID (omit to use default space)"),
243
262
  action: z
244
263
  .enum(["get_links", "export_history"])
245
264
  .optional()
@@ -247,8 +266,9 @@ export function registerProjectTools(server, client) {
247
266
  .describe("get_links: download URLs. export_history: past export records."),
248
267
  }, async ({ projectSeq, spaceSeq, action }) => {
249
268
  try {
269
+ const resolvedSpaceSeq = await resolver.resolve(spaceSeq);
250
270
  if (action === "export_history") {
251
- const basePath = `/video-translator/api/v1/projects/${projectSeq}/space/${spaceSeq}`;
271
+ const basePath = `/video-translator/api/v1/projects/${projectSeq}/space/${resolvedSpaceSeq}`;
252
272
  const history = await client.get(`${basePath}/export-history`);
253
273
  if (history.content.length === 0) {
254
274
  return textResult("No export history found.");
@@ -257,7 +277,7 @@ export function registerProjectTools(server, client) {
257
277
  return textResult(`Export History:\n${entries.join("\n")}`);
258
278
  }
259
279
  // get_links
260
- const basePath = `/video-translator/api/v1/projects/${projectSeq}/spaces/${spaceSeq}`;
280
+ const basePath = `/video-translator/api/v1/projects/${projectSeq}/spaces/${resolvedSpaceSeq}`;
261
281
  const links = await client.get(`${basePath}/download`);
262
282
  const parts = ["Download Links:"];
263
283
  if (links.video)
@@ -1,3 +1,4 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { PersoApiClient } from "../client.js";
3
- export declare function registerScriptTools(server: McpServer, client: PersoApiClient): void;
3
+ import type { SpaceResolver } from "../utils/space-resolver.js";
4
+ export declare function registerScriptTools(server: McpServer, client: PersoApiClient, resolver: SpaceResolver): void;
@@ -1,10 +1,13 @@
1
1
  import { z } from "zod";
2
2
  import { errorResult, textResult } from "../utils/formatting.js";
3
- export function registerScriptTools(server, client) {
3
+ export function registerScriptTools(server, client, resolver) {
4
4
  // Tool: get_script
5
- server.tool("get_script", "Get the translation script for a PERSO project. Returns sentences with original text, translated text, matching rate, and audio URLs. Supports cursor-based pagination.", {
5
+ server.tool("get_script", "Get the translation script for a PERSO project. Returns sentences with original text, translated text, matching rate, and audio URLs. Supports cursor-based pagination. spaceSeq is optional — omit to use the default space.", {
6
6
  projectSeq: z.number().describe("Project ID"),
7
- spaceSeq: z.number().describe("Space ID"),
7
+ spaceSeq: z
8
+ .number()
9
+ .optional()
10
+ .describe("Space ID (omit to use default space)"),
8
11
  cursorId: z
9
12
  .number()
10
13
  .optional()
@@ -16,7 +19,8 @@ export function registerScriptTools(server, client) {
16
19
  .describe("Number of sentences to return"),
17
20
  }, async ({ projectSeq, spaceSeq, cursorId, size }) => {
18
21
  try {
19
- const basePath = `/video-translator/api/v1/projects/${projectSeq}/spaces/${spaceSeq}/script`;
22
+ const resolvedSpaceSeq = await resolver.resolve(spaceSeq);
23
+ const basePath = `/video-translator/api/v1/projects/${projectSeq}/spaces/${resolvedSpaceSeq}/script`;
20
24
  const query = { size: String(size) };
21
25
  if (cursorId !== undefined)
22
26
  query.cursorId = String(cursorId);
@@ -123,9 +127,12 @@ export function registerScriptTools(server, client) {
123
127
  }
124
128
  });
125
129
  // Tool: request_proofread
126
- server.tool("request_proofread", "Request AI proofreading for all sentences in a PERSO translation project.", {
130
+ server.tool("request_proofread", "Request AI proofreading for all sentences in a PERSO translation project. spaceSeq is optional — omit to use the default space.", {
127
131
  projectSeq: z.number().describe("Project ID"),
128
- spaceSeq: z.number().describe("Space ID"),
132
+ spaceSeq: z
133
+ .number()
134
+ .optional()
135
+ .describe("Space ID (omit to use default space)"),
129
136
  isLipSync: z
130
137
  .boolean()
131
138
  .optional()
@@ -138,7 +145,8 @@ export function registerScriptTools(server, client) {
138
145
  .describe("GREEN = standard, RED = expedited"),
139
146
  }, async ({ projectSeq, spaceSeq, isLipSync, preferredSpeedType }) => {
140
147
  try {
141
- await client.post(`/video-translator/api/v1/project/${projectSeq}/space/${spaceSeq}/proofread`, { isLipSync, preferredSpeedType });
148
+ const resolvedSpaceSeq = await resolver.resolve(spaceSeq);
149
+ await client.post(`/video-translator/api/v1/project/${projectSeq}/space/${resolvedSpaceSeq}/proofread`, { isLipSync, preferredSpeedType });
142
150
  return textResult("Proofread request submitted successfully.");
143
151
  }
144
152
  catch (error) {
@@ -1,3 +1,4 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { PersoApiClient } from "../client.js";
3
- export declare function registerSpaceTools(server: McpServer, client: PersoApiClient): void;
3
+ import type { SpaceResolver } from "../utils/space-resolver.js";
4
+ export declare function registerSpaceTools(server: McpServer, client: PersoApiClient, resolver: SpaceResolver): void;
@@ -8,7 +8,7 @@ function formatSpace(space) {
8
8
  `Role: ${space.memberRole}`,
9
9
  ].join("\n");
10
10
  }
11
- export function registerSpaceTools(server, client) {
11
+ export function registerSpaceTools(server, client, resolver) {
12
12
  server.tool("list_spaces", "List all PERSO spaces the user belongs to, or get a specific space by ID. Returns space name, plan, member count, and role.", {
13
13
  spaceSeq: z
14
14
  .number()
@@ -30,4 +30,22 @@ export function registerSpaceTools(server, client) {
30
30
  return errorResult(error);
31
31
  }
32
32
  });
33
+ server.tool("set_default_space", "Set the default space for all subsequent API calls. Once set, you can omit spaceSeq in other tools. If not called, the first space is used automatically.", {
34
+ spaceSeq: z.number().describe("Space ID to set as default"),
35
+ }, async ({ spaceSeq }) => {
36
+ try {
37
+ // Verify the space exists
38
+ const space = await client.get(`/portal/api/v1/spaces/${spaceSeq}`);
39
+ resolver.setDefault(spaceSeq);
40
+ return textResult([
41
+ `Default space set successfully.`,
42
+ formatSpace(space),
43
+ ``,
44
+ `All tools will now use this space when spaceSeq is omitted.`,
45
+ ].join("\n"));
46
+ }
47
+ catch (error) {
48
+ return errorResult(error);
49
+ }
50
+ });
33
51
  }
@@ -1,3 +1,4 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { PersoApiClient } from "../client.js";
3
- export declare function registerUsageTools(server: McpServer, client: PersoApiClient): void;
3
+ import type { SpaceResolver } from "../utils/space-resolver.js";
4
+ export declare function registerUsageTools(server: McpServer, client: PersoApiClient, resolver: SpaceResolver): void;
@@ -1,8 +1,11 @@
1
1
  import { z } from "zod";
2
2
  import { errorResult, textResult } from "../utils/formatting.js";
3
- export function registerUsageTools(server, client) {
4
- server.tool("get_usage", "Get PERSO plan status, estimate quota usage for a media file, or check queue status.", {
5
- spaceSeq: z.number().describe("Space ID"),
3
+ export function registerUsageTools(server, client, resolver) {
4
+ server.tool("get_usage", "Get PERSO plan status, estimate quota usage for a media file, or check queue status. spaceSeq is optional — omit to use the default space.", {
5
+ spaceSeq: z
6
+ .number()
7
+ .optional()
8
+ .describe("Space ID (omit to use default space)"),
6
9
  action: z
7
10
  .enum(["plan_status", "estimate_quota", "queue_status"])
8
11
  .default("plan_status")
@@ -27,7 +30,8 @@ export function registerUsageTools(server, client) {
27
30
  .describe("Number of target languages (for estimate_quota)"),
28
31
  }, async ({ spaceSeq, action, mediaType, durationMs, lipSync, width, height, targetLanguageSize }) => {
29
32
  try {
30
- const basePath = `/video-translator/api/v1/projects/spaces/${spaceSeq}`;
33
+ const resolvedSpaceSeq = await resolver.resolve(spaceSeq);
34
+ const basePath = `/video-translator/api/v1/projects/spaces/${resolvedSpaceSeq}`;
31
35
  if (action === "plan_status") {
32
36
  const status = await client.get(`${basePath}/plan/status`);
33
37
  return textResult([
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;
@@ -0,0 +1,20 @@
1
+ import type { PersoApiClient } from "../client.js";
2
+ /**
3
+ * Caches the default spaceSeq in memory so tools don't need to
4
+ * repeatedly call the Spaces API.
5
+ *
6
+ * - resolve(spaceSeq?) → returns the provided value, or the cached default.
7
+ * - On first call without a value, fetches spaces from the API and caches the first one.
8
+ * - setDefault(spaceSeq) allows explicit override (e.g., via set_default_space tool).
9
+ */
10
+ export declare class SpaceResolver {
11
+ private defaultSpaceSeq;
12
+ private client;
13
+ constructor(client: PersoApiClient);
14
+ /** Resolve spaceSeq: use the given value, the cached default, or auto-fetch. */
15
+ resolve(spaceSeq?: number): Promise<number>;
16
+ /** Explicitly set the default spaceSeq. */
17
+ setDefault(spaceSeq: number): void;
18
+ /** Get the currently cached default (null if not yet resolved). */
19
+ getDefault(): number | null;
20
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Caches the default spaceSeq in memory so tools don't need to
3
+ * repeatedly call the Spaces API.
4
+ *
5
+ * - resolve(spaceSeq?) → returns the provided value, or the cached default.
6
+ * - On first call without a value, fetches spaces from the API and caches the first one.
7
+ * - setDefault(spaceSeq) allows explicit override (e.g., via set_default_space tool).
8
+ */
9
+ export class SpaceResolver {
10
+ defaultSpaceSeq = null;
11
+ client;
12
+ constructor(client) {
13
+ this.client = client;
14
+ }
15
+ /** Resolve spaceSeq: use the given value, the cached default, or auto-fetch. */
16
+ async resolve(spaceSeq) {
17
+ if (spaceSeq !== undefined) {
18
+ return spaceSeq;
19
+ }
20
+ if (this.defaultSpaceSeq !== null) {
21
+ return this.defaultSpaceSeq;
22
+ }
23
+ const spaces = await this.client.get("/portal/api/v1/spaces");
24
+ if (spaces.length === 0) {
25
+ throw new Error("No spaces found. Create a space on PERSO first, or provide spaceSeq explicitly.");
26
+ }
27
+ this.defaultSpaceSeq = spaces[0].spaceSeq;
28
+ return this.defaultSpaceSeq;
29
+ }
30
+ /** Explicitly set the default spaceSeq. */
31
+ setDefault(spaceSeq) {
32
+ this.defaultSpaceSeq = spaceSeq;
33
+ }
34
+ /** Get the currently cached default (null if not yet resolved). */
35
+ getDefault() {
36
+ return this.defaultSpaceSeq;
37
+ }
38
+ }
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.2",
4
4
  "description": "MCP server for PERSO API - video translation and dubbing platform",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",