@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/index.js
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* orchyn-mcp — MCP server exposing a single `analyze_video` tool that runs
|
|
4
|
+
* AI video analysis through the user's orchyn account.
|
|
5
|
+
*
|
|
6
|
+
* Modes:
|
|
7
|
+
* orchyn-mcp stdio transport (default; Claude Desktop, Cursor)
|
|
8
|
+
* orchyn-mcp login browser-based Google sign-in to orchyn
|
|
9
|
+
* orchyn-mcp --http remote HTTP transport with OAuth (OpenAI Agents SDK)
|
|
10
|
+
*/
|
|
11
|
+
import http from "node:http";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
17
|
+
import { getBaseUrl, getPublicUrl, getPort, getCredentialsFile, getTransportMode, DEFAULT_PORT, } from "./config.js";
|
|
18
|
+
import { AuthManager, OrchynAuthError, createHttpTokenProvider, createStdioTokenProvider } from "./auth.js";
|
|
19
|
+
import { OrchynClient, OrchynError } from "./orchyn.js";
|
|
20
|
+
import { OAuthManager } from "./oauth.js";
|
|
21
|
+
import { formatPaywallError, runVideoAnalysis, validateVideoUrl } from "./video.js";
|
|
22
|
+
const TOOL_NAME = "analyze_video";
|
|
23
|
+
const TOOL_DESCRIPTION = "Start an AI analysis of a TikTok, Instagram, or YouTube video from its link. " +
|
|
24
|
+
"Requires a connected orchyn account; consumes orchyn credits (first analysis free). " +
|
|
25
|
+
"Returns the analysis result once finished.";
|
|
26
|
+
const TOOL_INPUT_SCHEMA = z
|
|
27
|
+
.object({
|
|
28
|
+
url: z
|
|
29
|
+
.string()
|
|
30
|
+
.describe("Public video URL (tiktok.com, instagram.com, youtube.com, youtu.be, or common shortlinks)."),
|
|
31
|
+
})
|
|
32
|
+
.strict();
|
|
33
|
+
function toToolResult(proxy) {
|
|
34
|
+
const images = proxy.contentBlocks
|
|
35
|
+
.filter((c) => c.type === "image")
|
|
36
|
+
.map((c) => ({
|
|
37
|
+
type: "image",
|
|
38
|
+
data: String(c.data ?? ""),
|
|
39
|
+
mimeType: String(c.mimeType ?? "image/jpeg"),
|
|
40
|
+
}));
|
|
41
|
+
const text = JSON.stringify(proxy.structured ?? {}, null, 2);
|
|
42
|
+
return { content: [...images, { type: "text", text }] };
|
|
43
|
+
}
|
|
44
|
+
function toolError(prefix, err) {
|
|
45
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
46
|
+
return { content: [{ type: "text", text: `${prefix}: ${msg}` }], isError: true };
|
|
47
|
+
}
|
|
48
|
+
export function createServer(opts) {
|
|
49
|
+
const server = new McpServer({
|
|
50
|
+
name: "orchyn-mcp",
|
|
51
|
+
version: "1.0.0",
|
|
52
|
+
});
|
|
53
|
+
server.registerTool(TOOL_NAME, {
|
|
54
|
+
title: "Analyze Video",
|
|
55
|
+
description: TOOL_DESCRIPTION,
|
|
56
|
+
inputSchema: TOOL_INPUT_SCHEMA,
|
|
57
|
+
}, async (args, extra) => {
|
|
58
|
+
let session;
|
|
59
|
+
if (extra.authInfo?.token && opts.resolveSession) {
|
|
60
|
+
session = opts.resolveSession(extra.authInfo.token);
|
|
61
|
+
}
|
|
62
|
+
const client = opts.makeClient(session);
|
|
63
|
+
const validation = validateVideoUrl(args.url);
|
|
64
|
+
if (!validation.ok) {
|
|
65
|
+
return {
|
|
66
|
+
content: [{ type: "text", text: `Invalid url: ${validation.error}` }],
|
|
67
|
+
isError: true,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const result = await runVideoAnalysis(client, validation.url);
|
|
72
|
+
return {
|
|
73
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
if (err instanceof OrchynError && err.paywall) {
|
|
78
|
+
return {
|
|
79
|
+
content: [
|
|
80
|
+
{
|
|
81
|
+
type: "text",
|
|
82
|
+
text: `Analysis blocked: ${formatPaywallError(err)}\n\nHTTP ${err.status}: ${err.message}`,
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
isError: true,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
89
|
+
return {
|
|
90
|
+
content: [
|
|
91
|
+
{
|
|
92
|
+
type: "text",
|
|
93
|
+
text: `Video analysis failed: ${msg}`,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
isError: true,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
server.registerTool("get_social_media", {
|
|
101
|
+
title: "Get Social Media",
|
|
102
|
+
description: "Fetch a social post's media from a TikTok, Instagram, YouTube or X/Twitter URL: " +
|
|
103
|
+
"contentType (video/image/carousel/slideshow), title, caption, author, stats and direct media URLs. " +
|
|
104
|
+
"Returns an inline thumbnail image. Consumes 1 orchyn credit.",
|
|
105
|
+
inputSchema: z
|
|
106
|
+
.object({
|
|
107
|
+
url: z.string().describe("Full public post URL."),
|
|
108
|
+
})
|
|
109
|
+
.strict(),
|
|
110
|
+
}, async (args, extra) => {
|
|
111
|
+
const client = makeClientFor(extra, opts);
|
|
112
|
+
try {
|
|
113
|
+
return toToolResult(await client.callTool("get_social_media", { url: args.url }));
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
return toolError("get_social_media failed", err);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
server.registerTool("discover_social_videos", {
|
|
120
|
+
title: "Discover Social Videos",
|
|
121
|
+
description: "Discover recent videos/posts for a niche. YouTube via search; TikTok & Instagram via Apify. " +
|
|
122
|
+
"Consumes 2 orchyn credits.",
|
|
123
|
+
inputSchema: z
|
|
124
|
+
.object({
|
|
125
|
+
niche: z.string().describe("Niche/topic, e.g. 'fitness'."),
|
|
126
|
+
keywords: z.string().optional().describe("Optional extra keywords."),
|
|
127
|
+
limit: z.number().int().optional().describe("Max results (default 6)."),
|
|
128
|
+
platform: z
|
|
129
|
+
.enum(["youtube", "tiktok", "instagram", "any"])
|
|
130
|
+
.optional()
|
|
131
|
+
.describe("Platform to search (default youtube)."),
|
|
132
|
+
})
|
|
133
|
+
.strict(),
|
|
134
|
+
}, async (args, extra) => {
|
|
135
|
+
const client = makeClientFor(extra, opts);
|
|
136
|
+
try {
|
|
137
|
+
return toToolResult(await client.callTool("discover_social_videos", { ...args }));
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
return toolError("discover_social_videos failed", err);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
server.registerTool("understand_social_post", {
|
|
144
|
+
title: "Understand Social Post",
|
|
145
|
+
description: "Import a social post URL AND understand it with multimodal AI over the actual video/images: " +
|
|
146
|
+
"summary, hook strength, viral triggers, format breakdown and variation ideas. Includes the thumbnail. " +
|
|
147
|
+
"Consumes 10 orchyn credits.",
|
|
148
|
+
inputSchema: z
|
|
149
|
+
.object({
|
|
150
|
+
url: z.string().describe("Full public post URL (TikTok/Instagram/YouTube)."),
|
|
151
|
+
focus: z
|
|
152
|
+
.string()
|
|
153
|
+
.optional()
|
|
154
|
+
.describe("Extra instruction, e.g. 'focus on the CTA'."),
|
|
155
|
+
})
|
|
156
|
+
.strict(),
|
|
157
|
+
}, async (args, extra) => {
|
|
158
|
+
const client = makeClientFor(extra, opts);
|
|
159
|
+
try {
|
|
160
|
+
return toToolResult(await client.callTool("understand_social_post", { ...args }));
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
return toolError("understand_social_post failed", err);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
return server;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Resolves the per-request OrchynClient: HTTP-mode MCP sessions carry their
|
|
170
|
+
* own orchyn identity via authInfo; stdio uses the shared logged-in session.
|
|
171
|
+
*/
|
|
172
|
+
function makeClientFor(extra, opts) {
|
|
173
|
+
let session;
|
|
174
|
+
if (extra.authInfo?.token && opts.resolveSession) {
|
|
175
|
+
session = opts.resolveSession(extra.authInfo.token);
|
|
176
|
+
}
|
|
177
|
+
return opts.makeClient(session);
|
|
178
|
+
}
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// stdio mode
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
export async function runStdio() {
|
|
183
|
+
const auth = new AuthManager(getBaseUrl(), getCredentialsFile());
|
|
184
|
+
const server = createServer({
|
|
185
|
+
makeClient: (session) => new OrchynClient(getBaseUrl(), createStdioTokenProvider(auth)),
|
|
186
|
+
});
|
|
187
|
+
const transport = new StdioServerTransport();
|
|
188
|
+
await server.connect(transport);
|
|
189
|
+
// Keep the process alive until the transport closes (handled by the SDK).
|
|
190
|
+
}
|
|
191
|
+
function sendJson(res, status, body) {
|
|
192
|
+
const payload = JSON.stringify(body);
|
|
193
|
+
res.writeHead(status, {
|
|
194
|
+
"content-type": "application/json",
|
|
195
|
+
"content-length": Buffer.byteLength(payload),
|
|
196
|
+
"cache-control": "no-store",
|
|
197
|
+
});
|
|
198
|
+
res.end(payload);
|
|
199
|
+
}
|
|
200
|
+
function readJsonBody(req) {
|
|
201
|
+
return new Promise((resolve, reject) => {
|
|
202
|
+
const chunks = [];
|
|
203
|
+
req.on("data", (c) => chunks.push(Buffer.from(c)));
|
|
204
|
+
req.on("end", () => {
|
|
205
|
+
if (chunks.length === 0)
|
|
206
|
+
return resolve(undefined);
|
|
207
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
208
|
+
try {
|
|
209
|
+
resolve(JSON.parse(text));
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
resolve(undefined);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
req.on("error", reject);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
function sendHtml(res, status, body) {
|
|
219
|
+
const payload = `<!doctype html><html><head><meta charset="utf-8"><title>orchyn-mcp</title></head><body>${body}</body></html>`;
|
|
220
|
+
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
|
221
|
+
res.end(payload);
|
|
222
|
+
}
|
|
223
|
+
function bearerToken(req) {
|
|
224
|
+
const header = req.headers.authorization;
|
|
225
|
+
if (!header)
|
|
226
|
+
return undefined;
|
|
227
|
+
const match = /^Bearer\s+(.+)$/i.exec(header);
|
|
228
|
+
return match ? match[1].trim() : undefined;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* A request bearer token is valid when it was issued by our OAuth `/token`
|
|
232
|
+
* endpoint, or when it matches `ORCHYN_ACCESS_TOKEN` (pre-provisioned
|
|
233
|
+
* deployments and local testing — no browser OAuth round-trip needed).
|
|
234
|
+
*/
|
|
235
|
+
function validMcpToken(token, oauth) {
|
|
236
|
+
if (oauth.verifyToken(token))
|
|
237
|
+
return true;
|
|
238
|
+
const envToken = process.env.ORCHYN_ACCESS_TOKEN;
|
|
239
|
+
return typeof envToken === "string" && envToken.length > 0 && token === envToken;
|
|
240
|
+
}
|
|
241
|
+
async function handleMcpRequest(state, req, res) {
|
|
242
|
+
const token = bearerToken(req);
|
|
243
|
+
const session = token ? state.oauth.verifyToken(token) : undefined;
|
|
244
|
+
if (!token || !validMcpToken(token, state.oauth)) {
|
|
245
|
+
return sendJson(res, 401, {
|
|
246
|
+
error: "Unauthorized",
|
|
247
|
+
error_description: "This MCP server requires OAuth authentication. Fetch an access token from " +
|
|
248
|
+
`${state.oauth.authorizationServerMetadata().authorization_endpoint} first.`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
// Hand the validated token to the SDK transport, which forwards it to tool
|
|
252
|
+
// handlers via RequestHandlerExtra.authInfo (see shared/protocol.js).
|
|
253
|
+
req.auth = {
|
|
254
|
+
token,
|
|
255
|
+
clientId: session?.clientId ?? "orchyn-mcp",
|
|
256
|
+
scopes: session?.scopes ?? ["analyze:video"],
|
|
257
|
+
expiresAt: session
|
|
258
|
+
? Math.floor(session.expiresAt / 1000)
|
|
259
|
+
: Math.floor(Date.now() / 1000) + 3600,
|
|
260
|
+
};
|
|
261
|
+
const sessionId = req.headers["mcp-session-id"] ?? "";
|
|
262
|
+
let transport;
|
|
263
|
+
let parsedBody;
|
|
264
|
+
if (sessionId) {
|
|
265
|
+
transport = state.transports.get(sessionId);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
// No session id: an "initialize" request always starts a fresh session.
|
|
269
|
+
// Any other request is routed to the sole active session's transport
|
|
270
|
+
// (covers naive clients that never send the session id).
|
|
271
|
+
let isInitialize = false;
|
|
272
|
+
if (req.method === "POST") {
|
|
273
|
+
parsedBody = await readJsonBody(req);
|
|
274
|
+
const messages = Array.isArray(parsedBody) ? parsedBody : [parsedBody];
|
|
275
|
+
isInitialize = messages.some((m) => m !== null &&
|
|
276
|
+
typeof m === "object" &&
|
|
277
|
+
m.method === "initialize");
|
|
278
|
+
}
|
|
279
|
+
if (!isInitialize && state.transports.size === 1) {
|
|
280
|
+
transport = state.transports.values().next().value;
|
|
281
|
+
if (transport.sessionId) {
|
|
282
|
+
// The SDK's stateful transport requires the header on non-initialize
|
|
283
|
+
// requests; hono's node adapter reads req.rawHeaders, so patch both.
|
|
284
|
+
req.rawHeaders.push("mcp-session-id", transport.sessionId);
|
|
285
|
+
req.headers["mcp-session-id"] =
|
|
286
|
+
transport.sessionId;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (!transport) {
|
|
291
|
+
transport = new StreamableHTTPServerTransport({
|
|
292
|
+
sessionIdGenerator: () => randomUUID(),
|
|
293
|
+
enableJsonResponse: true,
|
|
294
|
+
onsessioninitialized: (sid) => {
|
|
295
|
+
state.transports.set(sid, transport);
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
// The SDK's McpServer can only attach to one transport, so each MCP
|
|
299
|
+
// session gets its own server instance (tools are registered per
|
|
300
|
+
// instance; sessions resolve their orchyn identity via authInfo).
|
|
301
|
+
const mcpserver = state.serverFactory();
|
|
302
|
+
state.connections.set(transport, mcpserver);
|
|
303
|
+
transport.onclose = () => {
|
|
304
|
+
state.connections.delete(transport);
|
|
305
|
+
if (transport?.sessionId)
|
|
306
|
+
state.transports.delete(transport.sessionId);
|
|
307
|
+
};
|
|
308
|
+
await mcpserver.connect(transport);
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
// If we already consumed the body to sniff for "initialize", hand the
|
|
312
|
+
// parsed payload to the transport so it doesn't try to re-read it.
|
|
313
|
+
if (parsedBody !== undefined) {
|
|
314
|
+
await transport.handleRequest(req, res, parsedBody);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
await transport.handleRequest(req, res);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
322
|
+
if (!res.headersSent) {
|
|
323
|
+
sendJson(res, 500, { error: `Internal error: ${msg}` });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
async function handleHttpRequest(state, req, res) {
|
|
328
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
329
|
+
const pathname = url.pathname;
|
|
330
|
+
if (pathname === "/.well-known/oauth-authorization-server" && req.method === "GET") {
|
|
331
|
+
return sendJson(res, 200, state.oauth.authorizationServerMetadata());
|
|
332
|
+
}
|
|
333
|
+
if (pathname === "/.well-known/oauth-protected-resource" && req.method === "GET") {
|
|
334
|
+
return sendJson(res, 200, state.oauth.protectedResourceMetadata());
|
|
335
|
+
}
|
|
336
|
+
if (pathname === "/authorize" && req.method === "GET") {
|
|
337
|
+
return state.oauth.handleAuthorize(req, res);
|
|
338
|
+
}
|
|
339
|
+
if (pathname === "/token" && req.method === "POST") {
|
|
340
|
+
return state.oauth.handleToken(req, res);
|
|
341
|
+
}
|
|
342
|
+
if (pathname === "/oauth/callback" && req.method === "GET") {
|
|
343
|
+
return state.oauth.handleCallback(req, res);
|
|
344
|
+
}
|
|
345
|
+
if (pathname === "/" && req.method === "GET") {
|
|
346
|
+
const meta = state.oauth.authorizationServerMetadata();
|
|
347
|
+
return sendHtml(res, 200, `<h1>orchyn-mcp</h1><p>MCP server is running.</p>` +
|
|
348
|
+
`<p>Authorization endpoint: <code>${meta.authorization_endpoint}</code></p>` +
|
|
349
|
+
`<p>Token endpoint: <code>${meta.token_endpoint}</code></p>`);
|
|
350
|
+
}
|
|
351
|
+
if (pathname === "/mcp" || pathname === "/") {
|
|
352
|
+
return handleMcpRequest(state, req, res);
|
|
353
|
+
}
|
|
354
|
+
return sendJson(res, 404, { error: "Not found" });
|
|
355
|
+
}
|
|
356
|
+
export async function runHttp(port, publicUrl) {
|
|
357
|
+
const baseUrl = getBaseUrl();
|
|
358
|
+
const pub = publicUrl ?? getPublicUrl();
|
|
359
|
+
const auth = new AuthManager(baseUrl, getCredentialsFile());
|
|
360
|
+
const oauth = new OAuthManager({
|
|
361
|
+
publicUrl: pub,
|
|
362
|
+
client: new OrchynClient(baseUrl, {
|
|
363
|
+
getAccessToken: async () => undefined,
|
|
364
|
+
}),
|
|
365
|
+
onSession: async (session) => {
|
|
366
|
+
await auth.persistSession(session);
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
const state = {
|
|
370
|
+
oauth,
|
|
371
|
+
auth,
|
|
372
|
+
transports: new Map(),
|
|
373
|
+
connections: new Map(),
|
|
374
|
+
serverFactory: () => createServer({
|
|
375
|
+
makeClient: (session) => new OrchynClient(baseUrl, createHttpTokenProvider(auth, session
|
|
376
|
+
? {
|
|
377
|
+
accessToken: session.orchynAccessToken,
|
|
378
|
+
refreshToken: session.orchynRefreshToken,
|
|
379
|
+
}
|
|
380
|
+
: undefined)),
|
|
381
|
+
resolveSession: (mcpAccessToken) => oauth.verifyToken(mcpAccessToken),
|
|
382
|
+
}),
|
|
383
|
+
};
|
|
384
|
+
const server = http.createServer((req, res) => {
|
|
385
|
+
handleHttpRequest(state, req, res).catch((err) => {
|
|
386
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
387
|
+
if (!res.headersSent)
|
|
388
|
+
sendJson(res, 500, { error: `Internal error: ${msg}` });
|
|
389
|
+
else
|
|
390
|
+
res.end();
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
server.listen(port, () => {
|
|
394
|
+
process.stdout.write(`[orchyn-mcp] HTTP server listening on ${pub.replace(/:\d+$/, "")}:${port}\n` +
|
|
395
|
+
`[orchyn-mcp] OAuth metadata: ${pub}/.well-known/oauth-authorization-server\n` +
|
|
396
|
+
`[orchyn-mcp] MCP endpoint: ${pub}/mcp\n`);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
export async function runLogin(opts) {
|
|
400
|
+
const baseUrl = getBaseUrl();
|
|
401
|
+
const auth = new AuthManager(baseUrl, getCredentialsFile());
|
|
402
|
+
const client = new OrchynClient(baseUrl, {
|
|
403
|
+
getAccessToken: async () => undefined,
|
|
404
|
+
});
|
|
405
|
+
if (opts.email && opts.password) {
|
|
406
|
+
const session = await client.login(opts.email, opts.password);
|
|
407
|
+
await auth.persistSession(session);
|
|
408
|
+
process.stdout.write(`Signed in as ${session.user?.email ?? opts.email}. Credentials saved to ${auth.getCredentialsFile()}\n`);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
// Browser flow with a loopback listener.
|
|
412
|
+
const callbackPath = "/oauth/callback";
|
|
413
|
+
const callbackUrl = `http://127.0.0.1:${opts.port}${callbackPath}`;
|
|
414
|
+
const googleStart = new URL("/auth/google/start", baseUrl);
|
|
415
|
+
googleStart.searchParams.set("redirect", callbackUrl);
|
|
416
|
+
const listener = http.createServer((req, res) => {
|
|
417
|
+
const reqUrl = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
418
|
+
if (reqUrl.pathname === callbackPath && req.method === "GET") {
|
|
419
|
+
const code = reqUrl.searchParams.get("code") ?? "";
|
|
420
|
+
if (code) {
|
|
421
|
+
client
|
|
422
|
+
.exchangeCompletionCode(code)
|
|
423
|
+
.then(async (session) => {
|
|
424
|
+
await auth.persistSession(session);
|
|
425
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
426
|
+
res.end("<html><body><h1>Signed in!</h1><p>You can close this tab and return to your terminal.</p></body></html>");
|
|
427
|
+
process.stdout.write(`Signed in as ${session.user?.email ?? "unknown user"}. Credentials saved to ${auth.getCredentialsFile()}\n`);
|
|
428
|
+
process.exit(0);
|
|
429
|
+
})
|
|
430
|
+
.catch((err) => {
|
|
431
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
432
|
+
res.writeHead(502, { "content-type": "text/html; charset=utf-8" });
|
|
433
|
+
res.end(`<html><body><h1>Sign-in failed</h1><p>${msg}</p></body></html>`);
|
|
434
|
+
process.stderr.write(`Sign-in failed: ${msg}\n`);
|
|
435
|
+
process.exit(1);
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
|
|
440
|
+
res.end("<html><body><h1>Sign-in failed</h1><p>No code returned.</p></body></html>");
|
|
441
|
+
process.exit(1);
|
|
442
|
+
}
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
res.writeHead(404).end();
|
|
446
|
+
});
|
|
447
|
+
await new Promise((resolve, reject) => {
|
|
448
|
+
listener.once("error", reject);
|
|
449
|
+
listener.listen(opts.port, "127.0.0.1", resolve);
|
|
450
|
+
});
|
|
451
|
+
process.stdout.write(`Open this URL in your browser to sign in with your orchyn account:\n\n ${googleStart.toString()}\n\n`);
|
|
452
|
+
try {
|
|
453
|
+
const { default: open } = await import("open");
|
|
454
|
+
await open(googleStart.toString());
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
process.stdout.write("Could not open the browser automatically. Copy the URL above into your browser.\n");
|
|
458
|
+
}
|
|
459
|
+
// Time out after 5 minutes.
|
|
460
|
+
setTimeout(() => {
|
|
461
|
+
process.stderr.write("Timed out waiting for sign-in.\n");
|
|
462
|
+
process.exit(1);
|
|
463
|
+
}, 300_000).unref();
|
|
464
|
+
}
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
// CLI
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
function printHelp() {
|
|
469
|
+
process.stdout.write(`orchyn-mcp — MCP server for orchyn AI video analysis
|
|
470
|
+
|
|
471
|
+
Usage:
|
|
472
|
+
orchyn-mcp Start in stdio mode (default transport)
|
|
473
|
+
orchyn-mcp --stdio Same as above
|
|
474
|
+
orchyn-mcp --http [--port N] Start the remote HTTP transport with OAuth
|
|
475
|
+
(default port ${DEFAULT_PORT}; also ORCHYN_PORT)
|
|
476
|
+
orchyn-mcp login Sign in to orchyn via Google in your browser
|
|
477
|
+
orchyn-mcp login --email me@example.com --password '...' Password login
|
|
478
|
+
orchyn-mcp --help Show this help
|
|
479
|
+
|
|
480
|
+
Environment variables:
|
|
481
|
+
ORCHYN_BASE_URL orchyn server base URL (default http://localhost:8080)
|
|
482
|
+
ORCHYN_ACCESS_TOKEN orchyn JWT access token (bypasses login)
|
|
483
|
+
ORCHYN_CREDENTIALS_FILE token store path (default ~/.config/orchyn-mcp/credentials.json)
|
|
484
|
+
ORCHYN_PUBLIC_URL public base URL for the HTTP mode (default http://localhost:3457)
|
|
485
|
+
ORCHYN_PORT port for --http and login (default ${DEFAULT_PORT})
|
|
486
|
+
ORCHYN_TRANSPORT "stdio" or "http"
|
|
487
|
+
|
|
488
|
+
Client setup:
|
|
489
|
+
Claude Desktop / Cursor (stdio): after "orchyn-mcp login", use
|
|
490
|
+
"command": "npx", "args": ["orchyn-mcp"] (plus ORCHYN_ACCESS_TOKEN if needed)
|
|
491
|
+
OpenAI Agents SDK (remote HTTP): use the RemoteMCPClient with URL
|
|
492
|
+
<ORCHYN_PUBLIC_URL>/mcp — the OAuth flow will open your browser.
|
|
493
|
+
|
|
494
|
+
See README.md for full instructions.
|
|
495
|
+
`);
|
|
496
|
+
}
|
|
497
|
+
async function main() {
|
|
498
|
+
const args = process.argv.slice(2);
|
|
499
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
500
|
+
printHelp();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (args[0] === "login") {
|
|
504
|
+
const rest = args.slice(1);
|
|
505
|
+
const valueOf = (flag) => {
|
|
506
|
+
const idx = rest.indexOf(flag);
|
|
507
|
+
return idx >= 0 && rest[idx + 1] ? rest[idx + 1] : undefined;
|
|
508
|
+
};
|
|
509
|
+
const email = valueOf("--email");
|
|
510
|
+
const password = valueOf("--password");
|
|
511
|
+
let port = getPort();
|
|
512
|
+
const portRaw = valueOf("--port");
|
|
513
|
+
if (portRaw !== undefined)
|
|
514
|
+
port = Number.parseInt(portRaw, 10);
|
|
515
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
516
|
+
process.stderr.write("Invalid --port value.\n");
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}
|
|
519
|
+
if (Boolean(email) !== Boolean(password)) {
|
|
520
|
+
process.stderr.write("Both --email and --password must be provided together.\n");
|
|
521
|
+
process.exit(1);
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
await runLogin({ email, password, port });
|
|
525
|
+
}
|
|
526
|
+
catch (err) {
|
|
527
|
+
process.stderr.write(`Login failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
528
|
+
process.exit(1);
|
|
529
|
+
}
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const isHttp = args.includes("--http") || getTransportMode() === "http";
|
|
533
|
+
let port = getPort();
|
|
534
|
+
if (args.includes("--port")) {
|
|
535
|
+
const idx = args.indexOf("--port");
|
|
536
|
+
if (args[idx + 1])
|
|
537
|
+
port = Number.parseInt(args[idx + 1], 10);
|
|
538
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
539
|
+
process.stderr.write("Invalid --port value.\n");
|
|
540
|
+
process.exit(1);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
if (isHttp) {
|
|
545
|
+
await runHttp(port);
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
await runStdio();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
catch (err) {
|
|
552
|
+
if (err instanceof OrchynAuthError) {
|
|
553
|
+
process.stderr.write(`${err.message}\n`);
|
|
554
|
+
}
|
|
555
|
+
else if (err instanceof OrchynError) {
|
|
556
|
+
process.stderr.write(`orchyn API error: ${err.message}\n`);
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
560
|
+
}
|
|
561
|
+
process.exit(1);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
main();
|