@orchyn/mcp 1.3.4 → 1.4.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/README.md +7 -4
- package/dist/index.js +8 -184
- package/dist/oauth.js +11 -60
- package/dist/orchyn.js +4 -162
- package/dist/shared/oauth.js +86 -0
- package/dist/shared/orchyn.js +235 -0
- package/dist/shared/tools-def.js +75 -0
- package/dist/shared/tools.js +300 -0
- package/dist/shared/video.js +152 -0
- package/dist/video.js +4 -139
- package/package.json +10 -2
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @orchyn/mcp
|
|
2
2
|
|
|
3
3
|
MCP (Model Context Protocol) server for [orchyn](https://orchyn.com) — fetch,
|
|
4
|
-
discover and understand social posts (TikTok, Instagram, YouTube, X
|
|
5
|
-
orchyn account and orchyn credits.
|
|
4
|
+
discover and understand social posts (TikTok, Instagram, YouTube, X/Twitter,
|
|
5
|
+
Douyin, Xiaohongshu, Bilibili) with your orchyn account and orchyn credits.
|
|
6
6
|
|
|
7
7
|
## Install (one link)
|
|
8
8
|
|
|
@@ -38,9 +38,9 @@ npx @orchyn/mcp login # one-time sign-in (Google)
|
|
|
38
38
|
|
|
39
39
|
| Tool | Credits | Description |
|
|
40
40
|
|------|---------|-------------|
|
|
41
|
-
| `analyze_post` | first free* | **Preferred.** Analyze any post (video, image, carousel/slideshow) from a TikTok/Instagram/YouTube/X-Twitter URL — imports the media and runs AI analysis over the actual content (video frames, carousel images, caption). Returns a `jobId` to poll via `GET /ai/analyze-post`. *First analysis free per workspace via the dashboard free grant.* |
|
|
41
|
+
| `analyze_post` | first free* | **Preferred.** Analyze any post (video, image, carousel/slideshow) from a TikTok/Instagram/YouTube/X-Twitter/Douyin/Xiaohongshu/Bilibili URL — imports the media and runs AI analysis over the actual content (video frames, carousel images, caption). Returns a `jobId` to poll via `GET /ai/analyze-post`. *First analysis free per workspace via the dashboard free grant.* |
|
|
42
42
|
| `get_social_media` | 1 | Fetch a post's media from a URL: `contentType` (video/image/carousel/slideshow), title, caption, author, stats, direct media URLs **+ inline thumbnail image in chat**. |
|
|
43
|
-
| `discover_social_posts` | 2 | **Preferred.** Find recent posts (video/image/carousel/slideshow) for a niche
|
|
43
|
+
| `discover_social_posts` | 2 | **Preferred.** Find recent posts (video/image/carousel/slideshow) for a niche on YouTube, TikTok, Instagram, Douyin, Xiaohongshu, X/Twitter or Bilibili (via TikHub). Each post includes title/caption, views/likes/comments, author, `externalUrl` + **inline thumbnails (4 at a time)** — see *Images in chat* below. Supports `limit`/`offset` pagination (“next”). |
|
|
44
44
|
| `understand_social_post` | 10 | Import a post URL **and** analyze it with multimodal AI over the actual video/images: factual `whatHappens` description, hook strength, viral triggers, format breakdown, variation ideas, suggested hook/hashtags. Includes inline thumbnails. |
|
|
45
45
|
| `check_orchyn_credits` | free | Check your MCP credit balance, billing URL and pack size — no cost. |
|
|
46
46
|
| `buy_orchyn_credits` | free | Get a Stripe Checkout URL to buy a credit pack — open it to pay; credits are added automatically. No cost to call. Also at `https://orchyn.com/settings?tab=billing`. |
|
|
@@ -245,6 +245,9 @@ per the MCP 2025-03-26 spec):
|
|
|
245
245
|
- Instagram: `instagram.com/*` (reels, posts, carousels), `instagr.am/*`
|
|
246
246
|
- YouTube: `youtube.com/*` (including `/shorts/`), `youtu.be/*`, `m.youtube.com/*`
|
|
247
247
|
- X/Twitter: `x.com/*`, `twitter.com/*`
|
|
248
|
+
- Douyin: `douyin.com/*`
|
|
249
|
+
- Xiaohongshu: `xiaohongshu.com/*`, `xhslink.com/*`
|
|
250
|
+
- Bilibili: `bilibili.com/*`, `b23.tv/*`
|
|
248
251
|
|
|
249
252
|
All tools accept these hosts and handle **video, image, carousel and slideshow** posts.
|
|
250
253
|
|
package/dist/index.js
CHANGED
|
@@ -10,197 +10,19 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import http from "node:http";
|
|
12
12
|
import { randomUUID } from "node:crypto";
|
|
13
|
-
import { z } from "zod";
|
|
14
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
13
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
14
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
17
15
|
import { getBaseUrl, getPublicUrl, getPort, getCredentialsFile, getTransportMode, DEFAULT_PORT, } from "./config.js";
|
|
18
16
|
import { AuthManager, OrchynAuthError, createHttpTokenProvider, createStdioTokenProvider } from "./auth.js";
|
|
19
17
|
import { OrchynClient, OrchynError } from "./orchyn.js";
|
|
20
18
|
import { OAuthManager } from "./oauth.js";
|
|
21
|
-
import {
|
|
22
|
-
function toToolResult(proxy) {
|
|
23
|
-
const images = proxy.contentBlocks
|
|
24
|
-
.filter((c) => c.type === "image")
|
|
25
|
-
.map((c) => ({
|
|
26
|
-
type: "image",
|
|
27
|
-
data: String(c.data ?? ""),
|
|
28
|
-
mimeType: String(c.mimeType ?? "image/jpeg"),
|
|
29
|
-
}));
|
|
30
|
-
const text = JSON.stringify(proxy.structured ?? {}, null, 2);
|
|
31
|
-
return { content: [...images, { type: "text", text }] };
|
|
32
|
-
}
|
|
33
|
-
function toolError(prefix, err) {
|
|
34
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
35
|
-
return { content: [{ type: "text", text: `${prefix}: ${msg}` }], isError: true };
|
|
36
|
-
}
|
|
37
|
-
export function createServer(opts) {
|
|
38
|
-
const server = new McpServer({
|
|
39
|
-
name: "orchyn-mcp",
|
|
40
|
-
version: "1.1.0",
|
|
41
|
-
});
|
|
42
|
-
server.registerTool("analyze_post", {
|
|
43
|
-
title: "Analyze Post",
|
|
44
|
-
description: "Analyze a social post (video, image, carousel/slideshow) from its link — " +
|
|
45
|
-
"imports the media and runs AI analysis over the actual content (video frames, carousel images, caption). " +
|
|
46
|
-
"Supports TikTok, Instagram, YouTube and X/Twitter. Returns the full analysis once finished.",
|
|
47
|
-
inputSchema: z
|
|
48
|
-
.object({
|
|
49
|
-
url: z.string().describe("Public post URL (TikTok/Instagram/YouTube/X or shortlinks)."),
|
|
50
|
-
})
|
|
51
|
-
.strict(),
|
|
52
|
-
}, async (args, extra) => {
|
|
53
|
-
let session;
|
|
54
|
-
if (extra.authInfo?.token && opts.resolveSession) {
|
|
55
|
-
session = opts.resolveSession(extra.authInfo.token);
|
|
56
|
-
}
|
|
57
|
-
const client = opts.makeClient(session);
|
|
58
|
-
const validation = validatePostUrl(args.url);
|
|
59
|
-
if (!validation.ok) {
|
|
60
|
-
return {
|
|
61
|
-
content: [{ type: "text", text: `Invalid url: ${validation.error}` }],
|
|
62
|
-
isError: true,
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
try {
|
|
66
|
-
const result = await runVideoAnalysis(client, validation.url);
|
|
67
|
-
const images = (result._inlineImages ?? []).map((img) => ({
|
|
68
|
-
type: "image",
|
|
69
|
-
data: String(img.data),
|
|
70
|
-
mimeType: String(img.mimeType ?? "image/jpeg"),
|
|
71
|
-
}));
|
|
72
|
-
// Remove internal field from JSON shown to model
|
|
73
|
-
const { _inlineImages: _omit, ...rest } = result;
|
|
74
|
-
return {
|
|
75
|
-
content: [...images, { type: "text", text: JSON.stringify(rest, null, 2) }],
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
catch (err) {
|
|
79
|
-
if (err instanceof OrchynError && err.paywall) {
|
|
80
|
-
return {
|
|
81
|
-
content: [{ type: "text", text: `Analysis blocked: ${formatPaywallError(err)}\n\nHTTP ${err.status}: ${err.message}` }],
|
|
82
|
-
isError: true,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
86
|
-
return { content: [{ type: "text", text: `Analysis failed: ${msg}` }], isError: true };
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
server.registerTool("get_social_media", {
|
|
90
|
-
title: "Get Social Media",
|
|
91
|
-
description: "Fetch a social post's media from a TikTok, Instagram, YouTube or X/Twitter URL: " +
|
|
92
|
-
"contentType (video/image/carousel/slideshow), title, caption, author, stats and direct media URLs. " +
|
|
93
|
-
"Returns an inline thumbnail image. Consumes 1 orchyn credit.",
|
|
94
|
-
inputSchema: z
|
|
95
|
-
.object({
|
|
96
|
-
url: z.string().describe("Full public post URL."),
|
|
97
|
-
})
|
|
98
|
-
.strict(),
|
|
99
|
-
}, async (args, extra) => {
|
|
100
|
-
const client = makeClientFor(extra, opts);
|
|
101
|
-
try {
|
|
102
|
-
return toToolResult(await client.callTool("get_social_media", { url: args.url }));
|
|
103
|
-
}
|
|
104
|
-
catch (err) {
|
|
105
|
-
return toolError("get_social_media failed", err);
|
|
106
|
-
}
|
|
107
|
-
});
|
|
108
|
-
server.registerTool("discover_social_posts", {
|
|
109
|
-
title: "Discover Social Posts",
|
|
110
|
-
description: "Discover recent posts (video, image, carousel, slideshow) for a niche. YouTube via search; TikTok & Instagram via Apify. " +
|
|
111
|
-
"Each post includes title/caption, thumbnailUrl, externalUrl, views/likes/comments and inline thumbnails (up to 4) so they show in chat. " +
|
|
112
|
-
"Say \"next\" to paginate (offset), or \"analyze the 2nd one\" / \"analyze all\" for batch analysis. Consumes 2 orchyn credits.",
|
|
113
|
-
inputSchema: z
|
|
114
|
-
.object({
|
|
115
|
-
niche: z.string().describe("Niche/topic, e.g. 'fitness'."),
|
|
116
|
-
keywords: z.string().optional().describe("Optional extra keywords."),
|
|
117
|
-
limit: z.number().int().optional().describe("Max results (default 6)."),
|
|
118
|
-
offset: z.number().int().optional().describe("Skip first N results — for 'next' pagination."),
|
|
119
|
-
platform: z
|
|
120
|
-
.enum(["youtube", "tiktok", "instagram", "any"])
|
|
121
|
-
.optional()
|
|
122
|
-
.describe("Platform to search (default youtube)."),
|
|
123
|
-
})
|
|
124
|
-
.strict(),
|
|
125
|
-
}, async (args, extra) => {
|
|
126
|
-
const client = makeClientFor(extra, opts);
|
|
127
|
-
try {
|
|
128
|
-
return toToolResult(await client.callTool("discover_social_posts", { ...args }));
|
|
129
|
-
}
|
|
130
|
-
catch (err) {
|
|
131
|
-
return toolError("discover_social_posts failed", err);
|
|
132
|
-
}
|
|
133
|
-
});
|
|
134
|
-
server.registerTool("check_orchyn_credits", {
|
|
135
|
-
title: "Check MCP Credits",
|
|
136
|
-
description: "Check your MCP credit balance, billing URL and pack size. No cost — call anytime to see remaining credits before running other tools.",
|
|
137
|
-
inputSchema: z.object({}).strict(),
|
|
138
|
-
}, async (_args, extra) => {
|
|
139
|
-
const client = makeClientFor(extra, opts);
|
|
140
|
-
try {
|
|
141
|
-
return toToolResult(await client.callTool("check_orchyn_credits", {}));
|
|
142
|
-
}
|
|
143
|
-
catch (err) {
|
|
144
|
-
return toolError("check_orchyn_credits failed", err);
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
-
server.registerTool("buy_orchyn_credits", {
|
|
148
|
-
title: "Buy MCP Credits",
|
|
149
|
-
description: "Buy an MCP credit pack via Stripe Checkout. Returns a secure checkout URL — open it in your browser to pay. Credits are added automatically after payment. No cost to call.",
|
|
150
|
-
inputSchema: z.object({}).strict(),
|
|
151
|
-
}, async (_args, extra) => {
|
|
152
|
-
const client = makeClientFor(extra, opts);
|
|
153
|
-
try {
|
|
154
|
-
return toToolResult(await client.callTool("buy_orchyn_credits", {}));
|
|
155
|
-
}
|
|
156
|
-
catch (err) {
|
|
157
|
-
return toolError("buy_orchyn_credits failed", err);
|
|
158
|
-
}
|
|
159
|
-
});
|
|
160
|
-
server.registerTool("understand_social_post", {
|
|
161
|
-
title: "Understand Social Post",
|
|
162
|
-
description: "Import a social post URL AND understand it with multimodal AI over the actual video/images: " +
|
|
163
|
-
"summary, hook strength, viral triggers, format breakdown and variation ideas. Includes the thumbnail. " +
|
|
164
|
-
"Consumes 10 orchyn credits.",
|
|
165
|
-
inputSchema: z
|
|
166
|
-
.object({
|
|
167
|
-
url: z.string().describe("Full public post URL (TikTok/Instagram/YouTube)."),
|
|
168
|
-
focus: z
|
|
169
|
-
.string()
|
|
170
|
-
.optional()
|
|
171
|
-
.describe("Extra instruction, e.g. 'focus on the CTA'."),
|
|
172
|
-
})
|
|
173
|
-
.strict(),
|
|
174
|
-
}, async (args, extra) => {
|
|
175
|
-
const client = makeClientFor(extra, opts);
|
|
176
|
-
try {
|
|
177
|
-
return toToolResult(await client.callTool("understand_social_post", { ...args }));
|
|
178
|
-
}
|
|
179
|
-
catch (err) {
|
|
180
|
-
return toolError("understand_social_post failed", err);
|
|
181
|
-
}
|
|
182
|
-
});
|
|
183
|
-
return server;
|
|
184
|
-
}
|
|
185
|
-
/**
|
|
186
|
-
* Resolves the per-request OrchynClient: HTTP-mode MCP sessions carry their
|
|
187
|
-
* own orchyn identity via authInfo; stdio uses the shared logged-in session.
|
|
188
|
-
*/
|
|
189
|
-
function makeClientFor(extra, opts) {
|
|
190
|
-
let session;
|
|
191
|
-
if (extra.authInfo?.token && opts.resolveSession) {
|
|
192
|
-
session = opts.resolveSession(extra.authInfo.token);
|
|
193
|
-
}
|
|
194
|
-
return opts.makeClient(session);
|
|
195
|
-
}
|
|
19
|
+
import { createMcpServer } from "./shared/tools.js";
|
|
196
20
|
// ---------------------------------------------------------------------------
|
|
197
21
|
// stdio mode
|
|
198
22
|
// ---------------------------------------------------------------------------
|
|
199
23
|
export async function runStdio() {
|
|
200
24
|
const auth = new AuthManager(getBaseUrl(), getCredentialsFile());
|
|
201
|
-
const server =
|
|
202
|
-
makeClient: (session) => new OrchynClient(getBaseUrl(), createStdioTokenProvider(auth)),
|
|
203
|
-
});
|
|
25
|
+
const server = createMcpServer(() => new OrchynClient(getBaseUrl(), createStdioTokenProvider(auth)));
|
|
204
26
|
const transport = new StdioServerTransport();
|
|
205
27
|
await server.connect(transport);
|
|
206
28
|
// Keep the process alive until the transport closes (handled by the SDK).
|
|
@@ -388,14 +210,16 @@ export async function runHttp(port, publicUrl) {
|
|
|
388
210
|
auth,
|
|
389
211
|
transports: new Map(),
|
|
390
212
|
connections: new Map(),
|
|
391
|
-
serverFactory: () =>
|
|
392
|
-
|
|
213
|
+
serverFactory: () => createMcpServer((extra) => {
|
|
214
|
+
const session = extra.authInfo?.token
|
|
215
|
+
? oauth.verifyToken(extra.authInfo.token)
|
|
216
|
+
: undefined;
|
|
217
|
+
return new OrchynClient(baseUrl, createHttpTokenProvider(auth, session
|
|
393
218
|
? {
|
|
394
219
|
accessToken: session.orchynAccessToken,
|
|
395
220
|
refreshToken: session.orchynRefreshToken,
|
|
396
221
|
}
|
|
397
|
-
: undefined))
|
|
398
|
-
resolveSession: (mcpAccessToken) => oauth.verifyToken(mcpAccessToken),
|
|
222
|
+
: undefined));
|
|
399
223
|
}),
|
|
400
224
|
};
|
|
401
225
|
const server = http.createServer((req, res) => {
|
package/dist/oauth.js
CHANGED
|
@@ -12,52 +12,13 @@
|
|
|
12
12
|
* The MCP access tokens issued at /token are opaque random strings bound to
|
|
13
13
|
* the orchyn JWT obtained through the Google sign-in flow.
|
|
14
14
|
*/
|
|
15
|
-
import {
|
|
16
|
-
export
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
}
|
|
15
|
+
import { SCOPE, escapeHtml, isAllowedRedirectUri, randomToken, verifyPkce, } from "./shared/oauth.js";
|
|
16
|
+
// Re-export the shared primitives so consumers and tests keep one import path.
|
|
17
|
+
export { SCOPE, verifyPkce, isAllowedRedirectUri, isLoopbackUrl, escapeHtml, generateState, } from "./shared/oauth.js";
|
|
18
|
+
// MCP session lifetime. Sessions self-renew their orchyn access token, so a
|
|
19
|
+
// login lasts as long as the account's refresh token (30 days server-side)
|
|
20
|
+
// rather than forcing a re-login every hour.
|
|
21
|
+
export const TOKEN_TTL_SECONDS = 604800;
|
|
61
22
|
function readBody(req) {
|
|
62
23
|
return new Promise((resolve, reject) => {
|
|
63
24
|
const chunks = [];
|
|
@@ -155,8 +116,8 @@ export class OAuthManager {
|
|
|
155
116
|
if (unsupported.length > 0) {
|
|
156
117
|
return this.sendAuthorizeError(res, params, "invalid_scope", `Unsupported scope(s): ${unsupported.join(", ")}. Supported: ${SCOPE}.`);
|
|
157
118
|
}
|
|
158
|
-
const orchynState =
|
|
159
|
-
const mcpAuthCode =
|
|
119
|
+
const orchynState = randomToken();
|
|
120
|
+
const mcpAuthCode = randomToken();
|
|
160
121
|
const pending = {
|
|
161
122
|
orchynState,
|
|
162
123
|
clientId,
|
|
@@ -256,12 +217,12 @@ export class OAuthManager {
|
|
|
256
217
|
return sendJson(res, 400, { error: "invalid_grant", error_description: "redirect_uri does not match the authorization request." });
|
|
257
218
|
}
|
|
258
219
|
const codeVerifier = params.get("code_verifier") ?? "";
|
|
259
|
-
if (!verifyPkce(codeVerifier, pending.codeChallenge)) {
|
|
220
|
+
if (!(await verifyPkce(codeVerifier, pending.codeChallenge))) {
|
|
260
221
|
return sendJson(res, 400, { error: "invalid_grant", error_description: "PKCE verification failed." });
|
|
261
222
|
}
|
|
262
223
|
this.pendingByMcpCode.delete(code);
|
|
263
224
|
this.pendingByOrchynState.delete(pending.orchynState);
|
|
264
|
-
const accessToken =
|
|
225
|
+
const accessToken = randomToken();
|
|
265
226
|
this.sessions.set(accessToken, {
|
|
266
227
|
orchynAccessToken: pending.orchynAccessToken ?? "",
|
|
267
228
|
orchynRefreshToken: pending.orchynRefreshToken,
|
|
@@ -291,13 +252,3 @@ export class OAuthManager {
|
|
|
291
252
|
return sendHtml(res, 400, "orchyn-mcp: bad request", `<p>${escapeHtml(description)}</p>`);
|
|
292
253
|
}
|
|
293
254
|
}
|
|
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
CHANGED
|
@@ -1,164 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* detected specifically and exposed via the `paywall` property.
|
|
2
|
+
* The orchyn API client now lives in `shared/orchyn.ts` so the Node package
|
|
3
|
+
* and the Cloudflare Worker import one implementation. This file re-exports
|
|
4
|
+
* it for backward compatibility.
|
|
6
5
|
*/
|
|
7
|
-
export
|
|
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
|
-
}
|
|
6
|
+
export * from "./shared/orchyn.js";
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-agnostic OAuth 2.0 / PKCE primitives shared by the Node package
|
|
3
|
+
* (`@orchyn/mcp`) and the Cloudflare Worker (mcp.orchyn.com). Web-standard
|
|
4
|
+
* APIs only (crypto, atob), so the worker's KV-backed OAuth flow and the
|
|
5
|
+
* node `OAuthManager` validate the same way by construction.
|
|
6
|
+
*/
|
|
7
|
+
export const SCOPE = "analyze:video";
|
|
8
|
+
export function isLoopbackUrl(url) {
|
|
9
|
+
let parsed;
|
|
10
|
+
try {
|
|
11
|
+
parsed = new URL(url);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
if (parsed.protocol !== "http:")
|
|
17
|
+
return false;
|
|
18
|
+
const host = parsed.hostname;
|
|
19
|
+
return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1";
|
|
20
|
+
}
|
|
21
|
+
/** redirect_uri must be loopback (http://localhost|127.0.0.1|[::1]) or any https URL. */
|
|
22
|
+
export function isAllowedRedirectUri(uri) {
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = new URL(uri);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
if (parsed.protocol === "https:")
|
|
31
|
+
return true;
|
|
32
|
+
if (parsed.protocol === "http:" && isLoopbackUrl(uri))
|
|
33
|
+
return true;
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
/** RFC 7636 S256 PKCE check (SHA-256 + base64url compare). */
|
|
37
|
+
export async function verifyPkce(codeVerifier, codeChallenge) {
|
|
38
|
+
if (!codeVerifier || !codeChallenge)
|
|
39
|
+
return false;
|
|
40
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
|
|
41
|
+
const bytes = new Uint8Array(digest);
|
|
42
|
+
let s = "";
|
|
43
|
+
for (const b of bytes)
|
|
44
|
+
s += String.fromCharCode(b);
|
|
45
|
+
const b64 = btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
46
|
+
return b64 === codeChallenge;
|
|
47
|
+
}
|
|
48
|
+
export function randomToken(bytes = 32) {
|
|
49
|
+
const buf = new Uint8Array(bytes);
|
|
50
|
+
crypto.getRandomValues(buf);
|
|
51
|
+
let s = "";
|
|
52
|
+
for (const b of buf)
|
|
53
|
+
s += String.fromCharCode(b);
|
|
54
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
55
|
+
}
|
|
56
|
+
export function authorizationServerMetadata(publicUrl, opts = {}) {
|
|
57
|
+
return {
|
|
58
|
+
issuer: publicUrl,
|
|
59
|
+
authorization_endpoint: `${publicUrl}/authorize`,
|
|
60
|
+
token_endpoint: `${publicUrl}/token`,
|
|
61
|
+
...(opts.registration
|
|
62
|
+
? { registration_endpoint: `${publicUrl}/register` }
|
|
63
|
+
: {}),
|
|
64
|
+
response_types_supported: ["code"],
|
|
65
|
+
code_challenge_methods_supported: ["S256"],
|
|
66
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
67
|
+
scopes_supported: [SCOPE],
|
|
68
|
+
grant_types_supported: ["authorization_code"],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export function protectedResourceMetadata(publicUrl) {
|
|
72
|
+
return {
|
|
73
|
+
resource: `${publicUrl}/mcp`,
|
|
74
|
+
authorization_servers: [publicUrl],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export function escapeHtml(input) {
|
|
78
|
+
return input
|
|
79
|
+
.replace(/&/g, "&")
|
|
80
|
+
.replace(/</g, "<")
|
|
81
|
+
.replace(/>/g, ">")
|
|
82
|
+
.replace(/"/g, """);
|
|
83
|
+
}
|
|
84
|
+
export function generateState() {
|
|
85
|
+
return crypto.randomUUID();
|
|
86
|
+
}
|