@orchyn/mcp 1.1.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 +285 -0
- package/dist/auth.js +170 -0
- package/dist/config.js +54 -0
- package/dist/index.js +564 -0
- package/dist/oauth.js +303 -0
- package/dist/orchyn.js +164 -0
- package/dist/video.js +129 -0
- package/package.json +37 -0
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand-rolled OAuth 2.0 authorization server (RFC 6749 + RFC 7636 PKCE)
|
|
3
|
+
* implementing the MCP 2025-03-26 OAuth spec subset needed by Claude
|
|
4
|
+
* Desktop, Cursor, and OpenAI Agents SDK:
|
|
5
|
+
*
|
|
6
|
+
* GET /.well-known/oauth-authorization-server
|
|
7
|
+
* GET /.well-known/oauth-protected-resource
|
|
8
|
+
* GET /authorize (Authorization Code + PKCE S256)
|
|
9
|
+
* POST /token (public client, no client auth)
|
|
10
|
+
* GET /oauth/callback (our own loopback: orchyn Google sign-in result)
|
|
11
|
+
*
|
|
12
|
+
* The MCP access tokens issued at /token are opaque random strings bound to
|
|
13
|
+
* the orchyn JWT obtained through the Google sign-in flow.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
16
|
+
export const SCOPE = "analyze:video";
|
|
17
|
+
export const TOKEN_TTL_SECONDS = 3600;
|
|
18
|
+
export function verifyPkce(codeVerifier, codeChallenge) {
|
|
19
|
+
if (!codeVerifier || !codeChallenge)
|
|
20
|
+
return false;
|
|
21
|
+
const digest = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
22
|
+
return digest === codeChallenge;
|
|
23
|
+
}
|
|
24
|
+
export function isLoopbackUrl(url) {
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = new URL(url);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
if (parsed.protocol !== "http:")
|
|
33
|
+
return false;
|
|
34
|
+
const host = parsed.hostname;
|
|
35
|
+
return (host === "localhost" ||
|
|
36
|
+
host === "127.0.0.1" ||
|
|
37
|
+
host === "[::1]" ||
|
|
38
|
+
host === "::1");
|
|
39
|
+
}
|
|
40
|
+
/** redirect_uri must be loopback (http://localhost|127.0.0.1|[::1]) or any https URL. */
|
|
41
|
+
export function isAllowedRedirectUri(uri) {
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = new URL(uri);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
if (parsed.protocol === "https:")
|
|
50
|
+
return true;
|
|
51
|
+
if (parsed.protocol === "http:" && isLoopbackUrl(uri))
|
|
52
|
+
return true;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
function base64Url(bytes) {
|
|
56
|
+
return bytes.toString("base64url");
|
|
57
|
+
}
|
|
58
|
+
function newToken() {
|
|
59
|
+
return base64Url(randomBytes(32));
|
|
60
|
+
}
|
|
61
|
+
function readBody(req) {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const chunks = [];
|
|
64
|
+
req.on("data", (c) => chunks.push(Buffer.from(c)));
|
|
65
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
66
|
+
req.on("error", reject);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function sendJson(res, status, body) {
|
|
70
|
+
const payload = JSON.stringify(body);
|
|
71
|
+
res.writeHead(status, {
|
|
72
|
+
"content-type": "application/json",
|
|
73
|
+
"content-length": Buffer.byteLength(payload),
|
|
74
|
+
"cache-control": "no-store",
|
|
75
|
+
});
|
|
76
|
+
res.end(payload);
|
|
77
|
+
}
|
|
78
|
+
function sendRedirect(res, location) {
|
|
79
|
+
res.writeHead(302, {
|
|
80
|
+
location,
|
|
81
|
+
"cache-control": "no-store",
|
|
82
|
+
});
|
|
83
|
+
res.end();
|
|
84
|
+
}
|
|
85
|
+
function sendHtml(res, status, title, body) {
|
|
86
|
+
const payload = `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head><body>${body}</body></html>`;
|
|
87
|
+
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
|
88
|
+
res.end(payload);
|
|
89
|
+
}
|
|
90
|
+
export class OAuthManager {
|
|
91
|
+
publicUrl;
|
|
92
|
+
client;
|
|
93
|
+
onSession;
|
|
94
|
+
pendingByOrchynState = new Map();
|
|
95
|
+
pendingByMcpCode = new Map();
|
|
96
|
+
sessions = new Map();
|
|
97
|
+
constructor(opts) {
|
|
98
|
+
this.publicUrl = opts.publicUrl.replace(/\/+$/, "");
|
|
99
|
+
this.client = opts.client;
|
|
100
|
+
this.onSession = opts.onSession;
|
|
101
|
+
}
|
|
102
|
+
/** GET /.well-known/oauth-authorization-server */
|
|
103
|
+
authorizationServerMetadata() {
|
|
104
|
+
return {
|
|
105
|
+
issuer: this.publicUrl,
|
|
106
|
+
authorization_endpoint: `${this.publicUrl}/authorize`,
|
|
107
|
+
token_endpoint: `${this.publicUrl}/token`,
|
|
108
|
+
response_types_supported: ["code"],
|
|
109
|
+
code_challenge_methods_supported: ["S256"],
|
|
110
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
111
|
+
scopes_supported: [SCOPE],
|
|
112
|
+
grant_types_supported: ["authorization_code"],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** GET /.well-known/oauth-protected-resource */
|
|
116
|
+
protectedResourceMetadata() {
|
|
117
|
+
return {
|
|
118
|
+
resource: `${this.publicUrl}/mcp`,
|
|
119
|
+
authorization_servers: [this.publicUrl],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** Verifies an MCP access token; returns the bound session or undefined. */
|
|
123
|
+
verifyToken(token) {
|
|
124
|
+
const session = this.sessions.get(token);
|
|
125
|
+
if (!session)
|
|
126
|
+
return undefined;
|
|
127
|
+
if (Date.now() > session.expiresAt) {
|
|
128
|
+
this.sessions.delete(token);
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
return session;
|
|
132
|
+
}
|
|
133
|
+
async handleAuthorize(req, res) {
|
|
134
|
+
const url = new URL(req.url ?? "/", this.publicUrl);
|
|
135
|
+
const params = url.searchParams;
|
|
136
|
+
if (params.get("response_type") !== "code") {
|
|
137
|
+
return this.sendAuthorizeError(res, params, "unsupported_response_type", "The authorization server only supports response_type=code.");
|
|
138
|
+
}
|
|
139
|
+
const codeChallenge = params.get("code_challenge") ?? "";
|
|
140
|
+
const method = params.get("code_challenge_method") ?? "";
|
|
141
|
+
if (!codeChallenge || method !== "S256") {
|
|
142
|
+
return this.sendAuthorizeError(res, params, "invalid_request", "PKCE is required: code_challenge and code_challenge_method=S256 must be provided.");
|
|
143
|
+
}
|
|
144
|
+
const clientId = params.get("client_id") ?? "";
|
|
145
|
+
if (!clientId) {
|
|
146
|
+
return this.sendAuthorizeError(res, params, "invalid_request", "Missing client_id.");
|
|
147
|
+
}
|
|
148
|
+
const redirectUri = params.get("redirect_uri") ?? "";
|
|
149
|
+
if (!redirectUri || !isAllowedRedirectUri(redirectUri)) {
|
|
150
|
+
return this.sendAuthorizeError(res, params, "invalid_request", "redirect_uri must be a loopback http://localhost, http://127.0.0.1, http://[::1] URL or an https URL.");
|
|
151
|
+
}
|
|
152
|
+
const scope = params.get("scope") ?? SCOPE;
|
|
153
|
+
const scopes = scope.split(/\s+/).filter(Boolean);
|
|
154
|
+
const unsupported = scopes.filter((s) => s !== SCOPE);
|
|
155
|
+
if (unsupported.length > 0) {
|
|
156
|
+
return this.sendAuthorizeError(res, params, "invalid_scope", `Unsupported scope(s): ${unsupported.join(", ")}. Supported: ${SCOPE}.`);
|
|
157
|
+
}
|
|
158
|
+
const orchynState = newToken();
|
|
159
|
+
const mcpAuthCode = newToken();
|
|
160
|
+
const pending = {
|
|
161
|
+
orchynState,
|
|
162
|
+
clientId,
|
|
163
|
+
redirectUri,
|
|
164
|
+
codeChallenge,
|
|
165
|
+
scopes,
|
|
166
|
+
mcpAuthCode,
|
|
167
|
+
clientState: params.get("state") ?? undefined,
|
|
168
|
+
createdAt: Date.now(),
|
|
169
|
+
};
|
|
170
|
+
this.pendingByOrchynState.set(orchynState, pending);
|
|
171
|
+
this.pendingByMcpCode.set(mcpAuthCode, pending);
|
|
172
|
+
// Ask the orchyn server to start a Google sign-in. We pre-seed ?state=
|
|
173
|
+
// with our own pending-request id; orchyn appends
|
|
174
|
+
// ?code=<completion>&redirect=... when it redirects back to us.
|
|
175
|
+
const ourCallback = `${this.publicUrl}/oauth/callback?state=${encodeURIComponent(orchynState)}`;
|
|
176
|
+
let redirectUrl;
|
|
177
|
+
try {
|
|
178
|
+
const res = await this.client.startGoogleSignIn(ourCallback);
|
|
179
|
+
redirectUrl = res.redirectUrl;
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
183
|
+
return sendHtml(res, 502, "orchyn-mcp: sign-in unavailable", `<p>Could not reach the orchyn server to start sign-in: ${escapeHtml(msg)}</p>`);
|
|
184
|
+
}
|
|
185
|
+
return sendRedirect(res, redirectUrl);
|
|
186
|
+
}
|
|
187
|
+
/** GET /oauth/callback?state=<ours>&code=<orchyn completion code>&redirect=<path> */
|
|
188
|
+
async handleCallback(req, res) {
|
|
189
|
+
const url = new URL(req.url ?? "/", this.publicUrl);
|
|
190
|
+
const orchynState = url.searchParams.get("state") ?? "";
|
|
191
|
+
const completionCode = url.searchParams.get("code") ?? "";
|
|
192
|
+
const pending = this.pendingByOrchynState.get(orchynState);
|
|
193
|
+
if (!pending) {
|
|
194
|
+
return sendHtml(res, 400, "orchyn-mcp: sign-in failed", "<p>Unknown or expired sign-in request. Please close this tab and try again.</p>");
|
|
195
|
+
}
|
|
196
|
+
if (!completionCode) {
|
|
197
|
+
return sendHtml(res, 400, "orchyn-mcp: sign-in failed", "<p>The orchyn sign-in did not return a completion code. Please close this tab and try again.</p>");
|
|
198
|
+
}
|
|
199
|
+
let session;
|
|
200
|
+
try {
|
|
201
|
+
session = await this.client.exchangeCompletionCode(completionCode);
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
205
|
+
return sendHtml(res, 502, "orchyn-mcp: sign-in failed", `<p>Could not complete sign-in with the orchyn server: ${escapeHtml(msg)}</p>`);
|
|
206
|
+
}
|
|
207
|
+
pending.completed = true;
|
|
208
|
+
pending.orchynAccessToken = session.accessToken;
|
|
209
|
+
pending.orchynRefreshToken = session.refreshToken;
|
|
210
|
+
pending.orchynUser = session.user
|
|
211
|
+
? { id: session.user.id, email: session.user.email, displayName: session.user.displayName }
|
|
212
|
+
: undefined;
|
|
213
|
+
// Courtesy persistence so stdio/CLI runs can reuse this session.
|
|
214
|
+
try {
|
|
215
|
+
await this.onSession?.(session);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Non-fatal: the in-memory session still works.
|
|
219
|
+
}
|
|
220
|
+
// Redirect the browser back to the MCP client with our one-time code.
|
|
221
|
+
const clientRedirect = new URL(pending.redirectUri);
|
|
222
|
+
clientRedirect.searchParams.set("code", pending.mcpAuthCode);
|
|
223
|
+
if (pending.clientState) {
|
|
224
|
+
clientRedirect.searchParams.set("state", pending.clientState);
|
|
225
|
+
}
|
|
226
|
+
return sendRedirect(res, clientRedirect.toString());
|
|
227
|
+
}
|
|
228
|
+
/** POST /token (form-encoded, public client, PKCE verified) */
|
|
229
|
+
async handleToken(req, res) {
|
|
230
|
+
let bodyText;
|
|
231
|
+
try {
|
|
232
|
+
bodyText = await readBody(req);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return sendJson(res, 400, { error: "invalid_request" });
|
|
236
|
+
}
|
|
237
|
+
const params = new URLSearchParams(bodyText);
|
|
238
|
+
const grantType = params.get("grant_type");
|
|
239
|
+
if (grantType !== "authorization_code") {
|
|
240
|
+
return sendJson(res, 400, {
|
|
241
|
+
error: "unsupported_grant_type",
|
|
242
|
+
error_description: "Only grant_type=authorization_code is supported.",
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
const code = params.get("code") ?? "";
|
|
246
|
+
const pending = this.pendingByMcpCode.get(code);
|
|
247
|
+
if (!pending || !pending.completed) {
|
|
248
|
+
return sendJson(res, 400, { error: "invalid_grant", error_description: "Unknown or incomplete authorization code." });
|
|
249
|
+
}
|
|
250
|
+
const clientId = params.get("client_id") ?? "";
|
|
251
|
+
if (pending.clientId !== clientId) {
|
|
252
|
+
return sendJson(res, 400, { error: "invalid_grant", error_description: "client_id does not match the authorization request." });
|
|
253
|
+
}
|
|
254
|
+
const redirectUri = params.get("redirect_uri");
|
|
255
|
+
if (redirectUri !== null && redirectUri !== pending.redirectUri) {
|
|
256
|
+
return sendJson(res, 400, { error: "invalid_grant", error_description: "redirect_uri does not match the authorization request." });
|
|
257
|
+
}
|
|
258
|
+
const codeVerifier = params.get("code_verifier") ?? "";
|
|
259
|
+
if (!verifyPkce(codeVerifier, pending.codeChallenge)) {
|
|
260
|
+
return sendJson(res, 400, { error: "invalid_grant", error_description: "PKCE verification failed." });
|
|
261
|
+
}
|
|
262
|
+
this.pendingByMcpCode.delete(code);
|
|
263
|
+
this.pendingByOrchynState.delete(pending.orchynState);
|
|
264
|
+
const accessToken = newToken();
|
|
265
|
+
this.sessions.set(accessToken, {
|
|
266
|
+
orchynAccessToken: pending.orchynAccessToken ?? "",
|
|
267
|
+
orchynRefreshToken: pending.orchynRefreshToken,
|
|
268
|
+
orchynUser: pending.orchynUser,
|
|
269
|
+
clientId: pending.clientId,
|
|
270
|
+
scopes: pending.scopes,
|
|
271
|
+
expiresAt: Date.now() + TOKEN_TTL_SECONDS * 1000,
|
|
272
|
+
});
|
|
273
|
+
return sendJson(res, 200, {
|
|
274
|
+
access_token: accessToken,
|
|
275
|
+
token_type: "Bearer",
|
|
276
|
+
expires_in: TOKEN_TTL_SECONDS,
|
|
277
|
+
scope: pending.scopes.join(" "),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
sendAuthorizeError(res, params, error, description) {
|
|
281
|
+
const redirectUri = params.get("redirect_uri") ?? "";
|
|
282
|
+
if (redirectUri && isAllowedRedirectUri(redirectUri)) {
|
|
283
|
+
const target = new URL(redirectUri);
|
|
284
|
+
target.searchParams.set("error", error);
|
|
285
|
+
target.searchParams.set("error_description", description);
|
|
286
|
+
const state = params.get("state");
|
|
287
|
+
if (state)
|
|
288
|
+
target.searchParams.set("state", state);
|
|
289
|
+
return sendRedirect(res, target.toString());
|
|
290
|
+
}
|
|
291
|
+
return sendHtml(res, 400, "orchyn-mcp: bad request", `<p>${escapeHtml(description)}</p>`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function escapeHtml(input) {
|
|
295
|
+
return input
|
|
296
|
+
.replace(/&/g, "&")
|
|
297
|
+
.replace(/</g, "<")
|
|
298
|
+
.replace(/>/g, ">")
|
|
299
|
+
.replace(/"/g, """);
|
|
300
|
+
}
|
|
301
|
+
export function generateState() {
|
|
302
|
+
return randomUUID();
|
|
303
|
+
}
|
package/dist/orchyn.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed client for the orchyn REST API.
|
|
3
|
+
*
|
|
4
|
+
* All API errors are normalized to `OrchynError`. 402 responses are
|
|
5
|
+
* detected specifically and exposed via the `paywall` property.
|
|
6
|
+
*/
|
|
7
|
+
export class OrchynError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
code;
|
|
10
|
+
paywall;
|
|
11
|
+
body;
|
|
12
|
+
constructor(status, message, opts = {}) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "OrchynError";
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.code = opts.code;
|
|
17
|
+
this.paywall = opts.paywall;
|
|
18
|
+
this.body = opts.body;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class OrchynClient {
|
|
22
|
+
baseUrl;
|
|
23
|
+
tokenProvider;
|
|
24
|
+
constructor(baseUrl, tokenProvider) {
|
|
25
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
26
|
+
this.tokenProvider = tokenProvider;
|
|
27
|
+
}
|
|
28
|
+
async request(method, path, opts = {}) {
|
|
29
|
+
const doRequest = async (accessToken) => {
|
|
30
|
+
const headers = {};
|
|
31
|
+
if (opts.body !== undefined) {
|
|
32
|
+
headers["content-type"] = "application/json";
|
|
33
|
+
}
|
|
34
|
+
if (accessToken) {
|
|
35
|
+
headers.authorization = `Bearer ${accessToken}`;
|
|
36
|
+
}
|
|
37
|
+
return fetch(`${this.baseUrl}${path}`, {
|
|
38
|
+
method,
|
|
39
|
+
headers,
|
|
40
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
let token = opts.token;
|
|
44
|
+
if (opts.auth && !token) {
|
|
45
|
+
token = await this.tokenProvider.getAccessToken();
|
|
46
|
+
}
|
|
47
|
+
if (opts.auth && !token) {
|
|
48
|
+
throw new OrchynError(401, "No orchyn access token available.");
|
|
49
|
+
}
|
|
50
|
+
let res = await doRequest(token);
|
|
51
|
+
if (res.status === 401 &&
|
|
52
|
+
opts.auth &&
|
|
53
|
+
this.tokenProvider.onUnauthorized) {
|
|
54
|
+
const refreshed = await this.tokenProvider.onUnauthorized();
|
|
55
|
+
if (refreshed) {
|
|
56
|
+
token = await this.tokenProvider.getAccessToken();
|
|
57
|
+
res = await doRequest(token);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return this.normalizeResponse(res, path);
|
|
61
|
+
}
|
|
62
|
+
async normalizeResponse(res, path) {
|
|
63
|
+
const text = await res.text();
|
|
64
|
+
let body;
|
|
65
|
+
try {
|
|
66
|
+
body = text ? JSON.parse(text) : undefined;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
body = undefined;
|
|
70
|
+
}
|
|
71
|
+
const json = (body ?? {});
|
|
72
|
+
const errorMessage = typeof json.error === "string" ? json.error : `orchyn API error (${res.status})`;
|
|
73
|
+
if (res.status >= 200 && res.status < 300) {
|
|
74
|
+
return body;
|
|
75
|
+
}
|
|
76
|
+
if (res.status === 402) {
|
|
77
|
+
throw new OrchynError(402, errorMessage, {
|
|
78
|
+
paywall: {
|
|
79
|
+
reason: typeof json.reason === "string" ? json.reason : undefined,
|
|
80
|
+
used: typeof json.used === "number" ? json.used : undefined,
|
|
81
|
+
max: typeof json.max === "number" ? json.max : undefined,
|
|
82
|
+
cost: typeof json.cost === "number" ? json.cost : undefined,
|
|
83
|
+
},
|
|
84
|
+
body,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
throw new OrchynError(res.status, errorMessage, {
|
|
88
|
+
code: typeof json.code === "string" ? json.code : undefined,
|
|
89
|
+
body,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
async startVideoAnalysis(url, appId) {
|
|
93
|
+
return this.request("POST", "/mcp/analyze-video", {
|
|
94
|
+
auth: true,
|
|
95
|
+
body: appId !== undefined ? { url, appId } : { url },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Proxies a generic orchyn backend MCP tool (`get_social_media`,
|
|
100
|
+
* `discover_social_videos`, `understand_social_post`, …) through
|
|
101
|
+
* `POST /mcp` JSON-RPC. The backend enforces per-user credit billing;
|
|
102
|
+
* tool-level failures surface as OrchynError with the backend message.
|
|
103
|
+
*/
|
|
104
|
+
async callTool(name, args) {
|
|
105
|
+
const rpc = await this.request("POST", "/mcp", {
|
|
106
|
+
auth: true,
|
|
107
|
+
body: {
|
|
108
|
+
jsonrpc: "2.0",
|
|
109
|
+
id: Date.now(),
|
|
110
|
+
method: "tools/call",
|
|
111
|
+
params: { name, arguments: args },
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
if (rpc && typeof rpc === "object" && rpc.error !== undefined && rpc.error !== null) {
|
|
115
|
+
const err = rpc.error;
|
|
116
|
+
throw new OrchynError(err.code === -32002 ? 402 : 400, typeof err.message === "string" ? err.message : "orchyn MCP tool call failed");
|
|
117
|
+
}
|
|
118
|
+
const result = (rpc.result ?? {});
|
|
119
|
+
if (result.isError) {
|
|
120
|
+
const text = result.content
|
|
121
|
+
?.filter((c) => c.type === "text")
|
|
122
|
+
.map((c) => String(c.text ?? ""))
|
|
123
|
+
.join("\n");
|
|
124
|
+
throw new OrchynError(400, text || "orchyn MCP tool call failed");
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
contentBlocks: result.content ?? [],
|
|
128
|
+
structured: result.structuredContent,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
async getJob(jobId) {
|
|
132
|
+
return this.request("GET", `/ai/analyze-post?jobId=${encodeURIComponent(jobId)}`, {
|
|
133
|
+
auth: true,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
async me() {
|
|
137
|
+
return this.request("GET", "/auth/me", { auth: true });
|
|
138
|
+
}
|
|
139
|
+
async exchangeCompletionCode(code, workspaceId) {
|
|
140
|
+
return this.request("POST", "/auth/oauth/complete", {
|
|
141
|
+
auth: false,
|
|
142
|
+
body: workspaceId !== undefined ? { code, workspaceId } : { code },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/** Starts a Google sign-in for the given redirect URL; returns the Google redirectUrl. */
|
|
146
|
+
async startGoogleSignIn(redirect) {
|
|
147
|
+
return this.request("POST", "/auth/google/start", {
|
|
148
|
+
auth: false,
|
|
149
|
+
body: { redirect },
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
async login(email, password) {
|
|
153
|
+
return this.request("POST", "/auth/login", {
|
|
154
|
+
auth: false,
|
|
155
|
+
body: { email, password },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
async refresh(refreshToken) {
|
|
159
|
+
return this.request("POST", "/auth/refresh", {
|
|
160
|
+
auth: false,
|
|
161
|
+
body: { refreshToken },
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
package/dist/video.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL validation + the analyze_video workflow: start the job, then poll until
|
|
3
|
+
* the analysis is done (or fails).
|
|
4
|
+
*/
|
|
5
|
+
export const POLL_INTERVAL_MS = 2000;
|
|
6
|
+
export const POLL_TIMEOUT_MS = 300_000;
|
|
7
|
+
const SUPPORTED_HOSTS = new Set([
|
|
8
|
+
"tiktok.com",
|
|
9
|
+
"vm.tiktok.com",
|
|
10
|
+
"instagram.com",
|
|
11
|
+
"instagr.am",
|
|
12
|
+
"youtube.com",
|
|
13
|
+
"youtu.be",
|
|
14
|
+
"m.youtube.com",
|
|
15
|
+
"youtube-nocookie.com",
|
|
16
|
+
"m.tiktok.com",
|
|
17
|
+
]);
|
|
18
|
+
export function validateVideoUrl(rawUrl) {
|
|
19
|
+
if (typeof rawUrl !== "string" || rawUrl.trim() === "") {
|
|
20
|
+
return { ok: false, error: "url must be a non-empty string." };
|
|
21
|
+
}
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = new URL(rawUrl.trim());
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return { ok: false, error: "url is not a valid URL." };
|
|
28
|
+
}
|
|
29
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
30
|
+
return { ok: false, error: "url must use http or https." };
|
|
31
|
+
}
|
|
32
|
+
let host = parsed.hostname.toLowerCase();
|
|
33
|
+
if (host.startsWith("www."))
|
|
34
|
+
host = host.slice(4);
|
|
35
|
+
if (host === "youtube.com" || host === "youtu.be") {
|
|
36
|
+
// accept all youtube.com paths, incl. /shorts/<id>
|
|
37
|
+
return { ok: true, url: parsed.toString() };
|
|
38
|
+
}
|
|
39
|
+
if (SUPPORTED_HOSTS.has(host)) {
|
|
40
|
+
return { ok: true, url: parsed.toString() };
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
error: "url host is not supported. Supported: tiktok.com, vm.tiktok.com, instagram.com, instagr.am, youtube.com, youtu.be, m.youtube.com (and /shorts).",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export class JobTimeoutError extends Error {
|
|
48
|
+
constructor(jobId, elapsedMs, lastStatus) {
|
|
49
|
+
super(`Timed out after ${Math.round(elapsedMs / 1000)}s waiting for analysis job ${jobId} to finish.` +
|
|
50
|
+
(lastStatus?.contentPreview ? ` Partial content: ${lastStatus.contentPreview}` : ""));
|
|
51
|
+
this.name = "JobTimeoutError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Polls a job until state is "done" or "error".
|
|
56
|
+
*/
|
|
57
|
+
export async function pollUntilDone(client, jobId, opts = {}) {
|
|
58
|
+
const pollIntervalMs = opts.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
59
|
+
const timeoutMs = opts.timeoutMs ?? POLL_TIMEOUT_MS;
|
|
60
|
+
const startedAt = Date.now();
|
|
61
|
+
let last;
|
|
62
|
+
for (;;) {
|
|
63
|
+
const elapsedMs = Date.now() - startedAt;
|
|
64
|
+
if (elapsedMs >= timeoutMs) {
|
|
65
|
+
throw new JobTimeoutError(jobId, elapsedMs, last);
|
|
66
|
+
}
|
|
67
|
+
const status = await client.getJob(jobId);
|
|
68
|
+
last = status;
|
|
69
|
+
opts.onPoll?.(status);
|
|
70
|
+
if (status.state === "done")
|
|
71
|
+
return status;
|
|
72
|
+
if (status.state === "error")
|
|
73
|
+
return status;
|
|
74
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Full tool workflow: start the analysis, poll until done, return a
|
|
79
|
+
* JSON-serializable result. Throws OrchynError on API-level failures
|
|
80
|
+
* (e.g. 402 paywall).
|
|
81
|
+
*/
|
|
82
|
+
export async function runVideoAnalysis(client, url, opts = {}) {
|
|
83
|
+
const job = await client.startVideoAnalysis(url, opts.appId);
|
|
84
|
+
const status = await pollUntilDone(client, job.jobId, opts);
|
|
85
|
+
const result = {
|
|
86
|
+
ok: status.state === "done",
|
|
87
|
+
jobId: status.jobId,
|
|
88
|
+
state: status.state,
|
|
89
|
+
platform: job.platform,
|
|
90
|
+
provider: status.provider ?? job.provider,
|
|
91
|
+
analysis: status.analysis,
|
|
92
|
+
contentPreview: status.contentPreview,
|
|
93
|
+
error: status.error,
|
|
94
|
+
elapsedMs: status.elapsedMs,
|
|
95
|
+
};
|
|
96
|
+
if (result.ok) {
|
|
97
|
+
result.job = {
|
|
98
|
+
ok: job.ok,
|
|
99
|
+
jobId: job.jobId,
|
|
100
|
+
state: job.state,
|
|
101
|
+
platform: job.platform,
|
|
102
|
+
provider: job.provider,
|
|
103
|
+
appId: job.appId,
|
|
104
|
+
workspaceId: job.workspaceId,
|
|
105
|
+
cost: job.cost,
|
|
106
|
+
freeGrant: job.freeGrant,
|
|
107
|
+
post: job.post,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
export function formatPaywallError(err) {
|
|
113
|
+
const p = err.paywall;
|
|
114
|
+
const parts = [];
|
|
115
|
+
if (p?.reason)
|
|
116
|
+
parts.push(`reason: ${p.reason}`);
|
|
117
|
+
if (p?.used !== undefined && p?.max !== undefined) {
|
|
118
|
+
parts.push(`credits used: ${p.used}/${p.max}`);
|
|
119
|
+
}
|
|
120
|
+
else if (p?.used !== undefined) {
|
|
121
|
+
parts.push(`credits used: ${p.used}`);
|
|
122
|
+
}
|
|
123
|
+
if (p?.cost !== undefined)
|
|
124
|
+
parts.push(`cost: ${p.cost}`);
|
|
125
|
+
const detail = parts.length > 0 ? ` (${parts.join(", ")})` : "";
|
|
126
|
+
return (`Your orchyn account has no credits left for this analysis${detail}. ` +
|
|
127
|
+
`Top up or check your usage in the orchyn dashboard, then try again. ` +
|
|
128
|
+
`Note: the first analysis is covered by the free grant.`);
|
|
129
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@orchyn/mcp",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "MCP server for orchyn - AI video analysis for TikTok, Instagram, and YouTube links",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE"
|
|
11
|
+
],
|
|
12
|
+
"bin": {
|
|
13
|
+
"orchyn-mcp": "dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"dev": "tsc -w",
|
|
21
|
+
"start": "node dist/index.js",
|
|
22
|
+
"login": "node dist/index.js login",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"prepare": "npm run build"
|
|
25
|
+
},
|
|
26
|
+
"keywords": ["mcp", "orchyn", "video", "analysis", "tiktok", "instagram", "youtube"],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@modelcontextprotocol/sdk": "^1.16.0",
|
|
30
|
+
"zod": "^3.24.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^22.0.0",
|
|
34
|
+
"typescript": "^5.5.0",
|
|
35
|
+
"vitest": "^3.1.0"
|
|
36
|
+
}
|
|
37
|
+
}
|