@orchyn/mcp 1.3.3 → 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 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) with your
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 (YouTube via `yt-dlp` search; TikTok/Instagram via Apify). 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”). |
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,188 +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 { formatPaywallError, runVideoAnalysis, validatePostUrl } from "./video.js";
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
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
68
- }
69
- catch (err) {
70
- if (err instanceof OrchynError && err.paywall) {
71
- return {
72
- content: [{ type: "text", text: `Analysis blocked: ${formatPaywallError(err)}\n\nHTTP ${err.status}: ${err.message}` }],
73
- isError: true,
74
- };
75
- }
76
- const msg = err instanceof Error ? err.message : String(err);
77
- return { content: [{ type: "text", text: `Analysis failed: ${msg}` }], isError: true };
78
- }
79
- });
80
- server.registerTool("get_social_media", {
81
- title: "Get Social Media",
82
- description: "Fetch a social post's media from a TikTok, Instagram, YouTube or X/Twitter URL: " +
83
- "contentType (video/image/carousel/slideshow), title, caption, author, stats and direct media URLs. " +
84
- "Returns an inline thumbnail image. Consumes 1 orchyn credit.",
85
- inputSchema: z
86
- .object({
87
- url: z.string().describe("Full public post URL."),
88
- })
89
- .strict(),
90
- }, async (args, extra) => {
91
- const client = makeClientFor(extra, opts);
92
- try {
93
- return toToolResult(await client.callTool("get_social_media", { url: args.url }));
94
- }
95
- catch (err) {
96
- return toolError("get_social_media failed", err);
97
- }
98
- });
99
- server.registerTool("discover_social_posts", {
100
- title: "Discover Social Posts",
101
- description: "Discover recent posts (video, image, carousel, slideshow) for a niche. YouTube via search; TikTok & Instagram via Apify. " +
102
- "Each post includes title/caption, thumbnailUrl, externalUrl, views/likes/comments and inline thumbnails (up to 4) so they show in chat. " +
103
- "Say \"next\" to paginate (offset), or \"analyze the 2nd one\" / \"analyze all\" for batch analysis. Consumes 2 orchyn credits.",
104
- inputSchema: z
105
- .object({
106
- niche: z.string().describe("Niche/topic, e.g. 'fitness'."),
107
- keywords: z.string().optional().describe("Optional extra keywords."),
108
- limit: z.number().int().optional().describe("Max results (default 6)."),
109
- offset: z.number().int().optional().describe("Skip first N results — for 'next' pagination."),
110
- platform: z
111
- .enum(["youtube", "tiktok", "instagram", "any"])
112
- .optional()
113
- .describe("Platform to search (default youtube)."),
114
- })
115
- .strict(),
116
- }, async (args, extra) => {
117
- const client = makeClientFor(extra, opts);
118
- try {
119
- return toToolResult(await client.callTool("discover_social_posts", { ...args }));
120
- }
121
- catch (err) {
122
- return toolError("discover_social_posts failed", err);
123
- }
124
- });
125
- server.registerTool("check_orchyn_credits", {
126
- title: "Check MCP Credits",
127
- description: "Check your MCP credit balance, billing URL and pack size. No cost — call anytime to see remaining credits before running other tools.",
128
- inputSchema: z.object({}).strict(),
129
- }, async (_args, extra) => {
130
- const client = makeClientFor(extra, opts);
131
- try {
132
- return toToolResult(await client.callTool("check_orchyn_credits", {}));
133
- }
134
- catch (err) {
135
- return toolError("check_orchyn_credits failed", err);
136
- }
137
- });
138
- server.registerTool("buy_orchyn_credits", {
139
- title: "Buy MCP Credits",
140
- 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.",
141
- inputSchema: z.object({}).strict(),
142
- }, async (_args, extra) => {
143
- const client = makeClientFor(extra, opts);
144
- try {
145
- return toToolResult(await client.callTool("buy_orchyn_credits", {}));
146
- }
147
- catch (err) {
148
- return toolError("buy_orchyn_credits failed", err);
149
- }
150
- });
151
- server.registerTool("understand_social_post", {
152
- title: "Understand Social Post",
153
- description: "Import a social post URL AND understand it with multimodal AI over the actual video/images: " +
154
- "summary, hook strength, viral triggers, format breakdown and variation ideas. Includes the thumbnail. " +
155
- "Consumes 10 orchyn credits.",
156
- inputSchema: z
157
- .object({
158
- url: z.string().describe("Full public post URL (TikTok/Instagram/YouTube)."),
159
- focus: z
160
- .string()
161
- .optional()
162
- .describe("Extra instruction, e.g. 'focus on the CTA'."),
163
- })
164
- .strict(),
165
- }, async (args, extra) => {
166
- const client = makeClientFor(extra, opts);
167
- try {
168
- return toToolResult(await client.callTool("understand_social_post", { ...args }));
169
- }
170
- catch (err) {
171
- return toolError("understand_social_post failed", err);
172
- }
173
- });
174
- return server;
175
- }
176
- /**
177
- * Resolves the per-request OrchynClient: HTTP-mode MCP sessions carry their
178
- * own orchyn identity via authInfo; stdio uses the shared logged-in session.
179
- */
180
- function makeClientFor(extra, opts) {
181
- let session;
182
- if (extra.authInfo?.token && opts.resolveSession) {
183
- session = opts.resolveSession(extra.authInfo.token);
184
- }
185
- return opts.makeClient(session);
186
- }
19
+ import { createMcpServer } from "./shared/tools.js";
187
20
  // ---------------------------------------------------------------------------
188
21
  // stdio mode
189
22
  // ---------------------------------------------------------------------------
190
23
  export async function runStdio() {
191
24
  const auth = new AuthManager(getBaseUrl(), getCredentialsFile());
192
- const server = createServer({
193
- makeClient: (session) => new OrchynClient(getBaseUrl(), createStdioTokenProvider(auth)),
194
- });
25
+ const server = createMcpServer(() => new OrchynClient(getBaseUrl(), createStdioTokenProvider(auth)));
195
26
  const transport = new StdioServerTransport();
196
27
  await server.connect(transport);
197
28
  // Keep the process alive until the transport closes (handled by the SDK).
@@ -379,14 +210,16 @@ export async function runHttp(port, publicUrl) {
379
210
  auth,
380
211
  transports: new Map(),
381
212
  connections: new Map(),
382
- serverFactory: () => createServer({
383
- makeClient: (session) => new OrchynClient(baseUrl, createHttpTokenProvider(auth, session
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
384
218
  ? {
385
219
  accessToken: session.orchynAccessToken,
386
220
  refreshToken: session.orchynRefreshToken,
387
221
  }
388
- : undefined)),
389
- resolveSession: (mcpAccessToken) => oauth.verifyToken(mcpAccessToken),
222
+ : undefined));
390
223
  }),
391
224
  };
392
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 { 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
- }
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 = newToken();
159
- const mcpAuthCode = newToken();
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 = newToken();
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, "&amp;")
297
- .replace(/</g, "&lt;")
298
- .replace(/>/g, "&gt;")
299
- .replace(/"/g, "&quot;");
300
- }
301
- export function generateState() {
302
- return randomUUID();
303
- }
package/dist/orchyn.js CHANGED
@@ -1,164 +1,6 @@
1
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.
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 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
- }
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, "&amp;")
80
+ .replace(/</g, "&lt;")
81
+ .replace(/>/g, "&gt;")
82
+ .replace(/"/g, "&quot;");
83
+ }
84
+ export function generateState() {
85
+ return crypto.randomUUID();
86
+ }