@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Orchyn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,285 @@
1
+ # @orchyn/mcp
2
+
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.
6
+
7
+ ## Install (one link)
8
+
9
+ **Claude Code** — register the marketplace, then install the plugin:
10
+
11
+ ```
12
+ /plugin marketplace add orchynX/mcp
13
+ /plugin install orchyn@orchyn
14
+ ```
15
+
16
+ **Claude Code / CLI without the plugin**:
17
+
18
+ ```bash
19
+ claude mcp add orchyn --user -- npx -y @orchyn/mcp
20
+ npx @orchyn/mcp login # one-time sign-in (Google)
21
+ ```
22
+
23
+ **Cursor / any stdio MCP client** (`claude_desktop_config.json`, `.mcp.json`, …):
24
+
25
+ ```json
26
+ {
27
+ "mcpServers": {
28
+ "orchyn": {
29
+ "command": "npx",
30
+ "args": ["-y", "@orchyn/mcp"],
31
+ "env": { "ORCHYN_BASE_URL": "https://api.orchyn.com" }
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ ## Tools
38
+
39
+ | Tool | Credits | Description |
40
+ |------|---------|-------------|
41
+ | `analyze_video` | first free | Start an AI analysis of a TikTok/Instagram/YouTube video; polls until done and returns the full analysis. |
42
+ | `get_social_media` | 1 | Fetch a post's media from a URL: contentType, title, caption, author, stats, direct media URLs + inline thumbnail image. |
43
+ | `discover_social_videos` | 2 | Find recent videos for a niche (YouTube search; TikTok/Instagram via Apify). |
44
+ | `understand_social_post` | 10 | Import a post URL AND analyze it with multimodal AI over the actual video/images: summary, hook strength, viral triggers, format breakdown, variation ideas. |
45
+
46
+ All tools require a connected orchyn account and are billed against your
47
+ orchyn credit balance (`POST /billing/mcp-credits/checkout` tops up).
48
+
49
+ The server starts the analysis on the orchyn backend, polls until the analysis
50
+ finishes, and returns the full result (analysis, job metadata, cost) as JSON.
51
+
52
+ ## Prerequisites
53
+
54
+ - Node.js >= 18 (tested on Node 22)
55
+ - An orchyn account (created automatically on first sign-in — Google sign-in
56
+ via `orchyn-mcp login`; the dashboard is **not** required: the server
57
+ auto-creates a default workspace + app for new accounts)
58
+ - Access to an orchyn server (default `http://localhost:8080`, see `ORCHYN_BASE_URL`)
59
+
60
+ ## Quick start
61
+
62
+ ```bash
63
+ npm install
64
+ npm run build
65
+
66
+ # 1. Sign in with your orchyn account (Google sign-in opens in your browser)
67
+ npx @orchyn/mcp login
68
+ # or with email/password:
69
+ npx @orchyn/mcp login --email you@example.com --password '...'
70
+
71
+ # 2. Run the server
72
+ npx @orchyn/mcp # stdio (default; for Claude Desktop / Cursor)
73
+ npx @orchyn/mcp --http # remote HTTP with OAuth (for OpenAI Agents SDK)
74
+ ```
75
+
76
+ `login` stores your orchyn tokens in `~/.config/orchyn-mcp/credentials.json`
77
+ (mode `0600`). If your browser cannot be opened automatically, copy the URL it
78
+ prints into a browser manually.
79
+
80
+ ## Usage in Claude Desktop
81
+
82
+ After `npx @orchyn/mcp login`, add to `claude_desktop_config.json`:
83
+
84
+ ```json
85
+ {
86
+ "mcpServers": {
87
+ "orchyn": {
88
+ "command": "npx",
89
+ "args": ["-y", "@orchyn/mcp"]
90
+ }
91
+ }
92
+ }
93
+ ```
94
+
95
+ Tokens are resolved from the credentials file written by `login` (or from
96
+ `ORCHYN_ACCESS_TOKEN`). If your client does not inherit your shell environment,
97
+ set the env vars explicitly:
98
+
99
+ ```json
100
+ {
101
+ "mcpServers": {
102
+ "orchyn": {
103
+ "command": "npx",
104
+ "args": ["-y", "@orchyn/mcp"],
105
+ "env": {
106
+ "ORCHYN_BASE_URL": "http://localhost:8080"
107
+ }
108
+ }
109
+ }
110
+ }
111
+ ```
112
+
113
+ ## Usage in Cursor
114
+
115
+ In `.mcp.json` (project root) or `~/.cursor/mcp.json`:
116
+
117
+ ```json
118
+ {
119
+ "mcpServers": {
120
+ "orchyn": {
121
+ "command": "npx",
122
+ "args": ["-y", "@orchyn/mcp"]
123
+ }
124
+ }
125
+ }
126
+ ```
127
+
128
+ After adding the server, run `npx @orchyn/mcp login` in your terminal — Cursor
129
+ spawns the server with your environment, so it picks up the stored credentials.
130
+
131
+ For remote/HTTP usage, Cursor can connect to the OAuth-enabled HTTP mode with
132
+ `npx @orchyn/mcp --http` running, pointing the server URL at
133
+ `http://localhost:3457/mcp`. Remote clients (including Cursor) discover the
134
+ OAuth endpoints from
135
+ `http://localhost:3457/.well-known/oauth-authorization-server`, open the
136
+ "Sign in with Google" page, and store the resulting access token.
137
+
138
+ ## Usage with OpenAI Agents SDK
139
+
140
+ Start the HTTP transport:
141
+
142
+ ```bash
143
+ npx @orchyn/mcp --http --port 3457
144
+ ```
145
+
146
+ Python:
147
+
148
+ ```python
149
+ from agents import Agent, Runner
150
+ from agents.mcp import RemoteMCPClient
151
+
152
+ async def main():
153
+ async with RemoteMCPClient(
154
+ url="http://localhost:3457/mcp",
155
+ auth_provider="oidc", # OAuth flow opens your browser once
156
+ ) as client:
157
+ agent = Agent(name="orchyn", mcp_servers=[client])
158
+ result = await Runner.run(
159
+ agent,
160
+ "Analyze this video: https://www.youtube.com/watch?v=dQw4w9WgXcQ",
161
+ )
162
+ print(result.final_output)
163
+ ```
164
+
165
+ TypeScript (Agents SDK):
166
+
167
+ ```ts
168
+ import { Agent } from "agents";
169
+ import { RemoteMCPClient } from "agents/mcp/client";
170
+
171
+ const client = new RemoteMCPClient({
172
+ url: "http://localhost:3457/mcp",
173
+ authProvider: "oidc", // opens the browser for the OAuth flow
174
+ });
175
+
176
+ const agent = new Agent({
177
+ name: "orchyn",
178
+ mcpServers: [client],
179
+ });
180
+
181
+ const result = await agent.run(
182
+ "Analyze this video: https://vm.tiktok.com/abc123/",
183
+ );
184
+ console.log(result.output);
185
+ ```
186
+
187
+ For a local stdio process with the Agents SDK, use `StdioMCPClient` (Python:
188
+ `StdioMCPClient(command="npx", args=["orchyn-mcp"])`).
189
+
190
+ ## Command line
191
+
192
+ ```
193
+ orchyn-mcp Start in stdio mode (default transport)
194
+ orchyn-mcp --stdio Same as above
195
+ orchyn-mcp --http [--port N] Start the remote HTTP transport with OAuth (default port 3457)
196
+ orchyn-mcp login Sign in to orchyn via Google in your browser
197
+ orchyn-mcp login --email ... --password ... Password login
198
+ orchyn-mcp --help Show help
199
+ ```
200
+
201
+ ## Environment variables
202
+
203
+ | Variable | Default | Description |
204
+ |----------|---------|-------------|
205
+ | `ORCHYN_BASE_URL` | `http://localhost:8080` | orchyn server base URL (trailing slash stripped) |
206
+ | `ORCHYN_ACCESS_TOKEN` | — | orchyn JWT access token; takes priority over the credentials file |
207
+ | `ORCHYN_CREDENTIALS_FILE` | `~/.config/orchyn-mcp/credentials.json` | token store path |
208
+ | `ORCHYN_PUBLIC_URL` | `http://localhost:3457` | public base URL advertised in OAuth metadata (HTTP mode) |
209
+ | `ORCHYN_PORT` | `3457` | port for `--http` and `login` |
210
+ | `ORCHYN_TRANSPORT` | `stdio` | `stdio` or `http` (same as `--http`) |
211
+
212
+ ## How authentication works
213
+
214
+ **stdio mode** (Claude Desktop, Cursor): the server uses the token from
215
+ `ORCHYN_ACCESS_TOKEN` or the credentials file written by `login`. If the token
216
+ is expired it is automatically refreshed with the stored refresh token, and if
217
+ the orchyn API returns `401` the request is retried once after a refresh.
218
+
219
+ **HTTP mode** (OpenAI Agents SDK, remote clients): the server runs its own
220
+ OAuth 2.0 authorization server (Authorization Code + PKCE S256, public client,
221
+ per the MCP 2025-03-26 spec):
222
+
223
+ - `GET /.well-known/oauth-authorization-server` — metadata
224
+ - `GET /authorize` — validates the request (loopback or https redirect URIs)
225
+ and forwards the browser to orchyn's Google sign-in
226
+ - `GET /oauth/callback` — our own loopback callback; exchanges orchyn's
227
+ completion code for orchyn JWTs and redirects back to the MCP client with a
228
+ one-time code
229
+ - `POST /token` — verifies PKCE and issues an opaque Bearer token bound to the
230
+ orchyn session (valid 1 hour)
231
+ - every MCP RPC validates the Bearer token against the session map
232
+
233
+ ## Supported video URLs
234
+
235
+ - TikTok: `tiktok.com/*`, `vm.tiktok.com/*` (and `www.`/`m.` subdomains)
236
+ - Instagram: `instagram.com/*` (reels, posts), `instagr.am/*`
237
+ - YouTube: `youtube.com/*` (including `/shorts/`), `youtu.be/*`, `m.youtube.com/*`
238
+
239
+ Other hosts are rejected by the tool.
240
+
241
+ ## Troubleshooting
242
+
243
+ - **`Not authenticated with orchyn` / 401**: run `npx @orchyn/mcp login` or set
244
+ `ORCHYN_ACCESS_TOKEN`.
245
+ - **402 paywall (`Analysis blocked`)**: your orchyn account has no credits
246
+ left. The error includes `reason`, `used`/`max`, and `cost`. Top up or check
247
+ your usage in the orchyn dashboard. The first analysis is covered by the
248
+ free grant.
249
+ - **Expired refresh token**: the stored refresh token was rejected by the
250
+ orchyn server. Run `npx @orchyn/mcp login` again to re-authenticate.
251
+ - **`Could not reach the orchyn server`**: `ORCHYN_BASE_URL` is unreachable or
252
+ wrong.
253
+ - **Client shows "Bad Request" or connection errors in HTTP mode**: make sure
254
+ the port matches `ORCHYN_PUBLIC_URL` and that the client fetched a token
255
+ first (the OAuth flow must complete once in your browser).
256
+
257
+ ## Platform submissions
258
+
259
+ See [docs/SUBMISSION.md](docs/SUBMISSION.md) for ready-to-paste configs and the submission
260
+ package for **Claude Desktop**, **Cursor**, and **OpenAI Agents SDK**.
261
+
262
+ ## Security notes
263
+
264
+ - The credentials file is written with mode `0600` and the directory with
265
+ `0700`.
266
+ - OAuth redirect URIs are restricted to loopback (`http://localhost`,
267
+ `http://127.0.0.1`, `http://[::1]`) or `https://` URLs; the `/authorize`
268
+ endpoint requires PKCE (`S256`).
269
+ - Authorization codes and PKCE challenges are one-time use and short-lived
270
+ (in-memory).
271
+ - Access tokens are opaque, random, and bound to the in-memory session map;
272
+ they expire after 1 hour. Restarting the server invalidates all sessions.
273
+ - Never share your credentials file or `ORCHYN_ACCESS_TOKEN`.
274
+
275
+ ## Development
276
+
277
+ ```bash
278
+ npm install
279
+ npm run build # tsc
280
+ npm test # vitest (54 unit tests, mocked fetch — no network)
281
+ ```
282
+
283
+ ## License
284
+
285
+ MIT
package/dist/auth.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Token storage + resolution.
3
+ *
4
+ * Priority: env ORCHYN_ACCESS_TOKEN > credentials file (auto-refresh when
5
+ * expired) > per-session tokens (HTTP OAuth mode only).
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { OrchynClient, OrchynError } from "./orchyn.js";
10
+ const REFRESH_BEFORE_EXPIRY_MS = 30_000;
11
+ export class OrchynAuthError extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = "OrchynAuthError";
15
+ }
16
+ }
17
+ export function isTokenExpired(store, nowMs = Date.now()) {
18
+ if (!store.expiresIn || !store.fetchedAt)
19
+ return false;
20
+ const expiresAtMs = store.fetchedAt + store.expiresIn * 1000;
21
+ return nowMs >= expiresAtMs - REFRESH_BEFORE_EXPIRY_MS;
22
+ }
23
+ export class AuthManager {
24
+ baseUrl;
25
+ credentialsFile;
26
+ client;
27
+ lastRefreshToken;
28
+ lastSource;
29
+ constructor(baseUrl, credentialsFile) {
30
+ this.baseUrl = baseUrl;
31
+ this.credentialsFile = credentialsFile;
32
+ this.client = new OrchynClient(baseUrl, {
33
+ getAccessToken: async () => undefined,
34
+ });
35
+ }
36
+ getCredentialsFile() {
37
+ return this.credentialsFile;
38
+ }
39
+ async loadStore() {
40
+ try {
41
+ const raw = await fs.promises.readFile(this.credentialsFile, "utf8");
42
+ const parsed = JSON.parse(raw);
43
+ if (parsed && typeof parsed.accessToken === "string") {
44
+ return parsed;
45
+ }
46
+ return null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ async saveStore(store) {
53
+ const dir = path.dirname(this.credentialsFile);
54
+ await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
55
+ const tmp = `${this.credentialsFile}.tmp`;
56
+ await fs.promises.writeFile(tmp, JSON.stringify(store, null, 2), {
57
+ mode: 0o600,
58
+ });
59
+ await fs.promises.chmod(tmp, 0o600);
60
+ await fs.promises.rename(tmp, this.credentialsFile);
61
+ await fs.promises.chmod(this.credentialsFile, 0o600);
62
+ }
63
+ /**
64
+ * Resolves the current access token for a request. `session` provides the
65
+ * per-session tokens issued via our own OAuth /token endpoint (HTTP mode).
66
+ */
67
+ async getAccessToken(session) {
68
+ const envToken = process.env.ORCHYN_ACCESS_TOKEN;
69
+ if (envToken) {
70
+ this.lastSource = "env";
71
+ this.lastRefreshToken = undefined;
72
+ return envToken;
73
+ }
74
+ const store = await this.loadStore();
75
+ if (store?.accessToken && !isTokenExpired(store)) {
76
+ this.lastSource = "file";
77
+ this.lastRefreshToken = store.refreshToken;
78
+ return store.accessToken;
79
+ }
80
+ if (store?.refreshToken) {
81
+ try {
82
+ const refreshed = await this.refresh(store.refreshToken);
83
+ return refreshed;
84
+ }
85
+ catch (err) {
86
+ // Fall through to session/env resolution; the file refresh will be
87
+ // retried lazily on the next request.
88
+ const msg = err instanceof Error ? err.message : String(err);
89
+ process.stderr.write(`[orchyn-mcp] warning: could not refresh stored token: ${msg}\n`);
90
+ }
91
+ }
92
+ if (session?.accessToken) {
93
+ this.lastSource = "session";
94
+ this.lastRefreshToken = session.refreshToken;
95
+ return session.accessToken;
96
+ }
97
+ return undefined;
98
+ }
99
+ /**
100
+ * Attempts to refresh using the refresh token of the source that supplied
101
+ * the current token. Returns true when a new token is available.
102
+ */
103
+ async onUnauthorized(session) {
104
+ let refreshToken = this.lastRefreshToken;
105
+ if (this.lastSource === "session" && session?.refreshToken) {
106
+ refreshToken = session.refreshToken;
107
+ }
108
+ if (!refreshToken)
109
+ return false;
110
+ try {
111
+ await this.refresh(refreshToken);
112
+ return true;
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ }
118
+ /** Refreshes with the given refresh token, persists, and returns the new access token. */
119
+ async refresh(refreshToken) {
120
+ const session = await this.client.refresh(refreshToken);
121
+ if (!session.accessToken) {
122
+ throw new OrchynError(500, "Refresh succeeded but returned no access token.");
123
+ }
124
+ const store = {
125
+ accessToken: session.accessToken,
126
+ refreshToken: session.refreshToken ?? refreshToken,
127
+ expiresIn: session.expiresIn,
128
+ fetchedAt: Date.now(),
129
+ user: session.user,
130
+ };
131
+ await this.saveStore(store);
132
+ this.lastSource = "file";
133
+ this.lastRefreshToken = store.refreshToken;
134
+ return store.accessToken;
135
+ }
136
+ /**
137
+ * Persists a session obtained from login/OAuth completion.
138
+ */
139
+ async persistSession(session) {
140
+ const store = {
141
+ accessToken: session.accessToken,
142
+ refreshToken: session.refreshToken,
143
+ expiresIn: session.expiresIn,
144
+ fetchedAt: Date.now(),
145
+ user: session.user,
146
+ };
147
+ await this.saveStore(store);
148
+ this.lastSource = "file";
149
+ this.lastRefreshToken = store.refreshToken;
150
+ }
151
+ ensureUnauthenticatedError() {
152
+ throw new OrchynAuthError("Not authenticated with orchyn. Run `npx orchyn-mcp login` to sign in " +
153
+ "with Google, or set the ORCHYN_ACCESS_TOKEN environment variable " +
154
+ "(see `npx orchyn-mcp --help`).");
155
+ }
156
+ }
157
+ /** Builds a TokenProvider for stdio mode (env + file only). */
158
+ export function createStdioTokenProvider(auth) {
159
+ return {
160
+ getAccessToken: async () => auth.getAccessToken(),
161
+ onUnauthorized: async () => auth.onUnauthorized(),
162
+ };
163
+ }
164
+ /** Builds a TokenProvider for HTTP mode: env > file > per-session tokens. */
165
+ export function createHttpTokenProvider(auth, session) {
166
+ return {
167
+ getAccessToken: async () => auth.getAccessToken(session),
168
+ onUnauthorized: async () => auth.onUnauthorized(session),
169
+ };
170
+ }
package/dist/config.js ADDED
@@ -0,0 +1,54 @@
1
+ import path from "node:path";
2
+ import os from "node:os";
3
+ export const DEFAULT_BASE_URL = "http://localhost:8080";
4
+ export const DEFAULT_PUBLIC_URL = "http://localhost:3457";
5
+ export const DEFAULT_PORT = 3457;
6
+ export function stripTrailingSlash(url) {
7
+ return url.replace(/\/+$/, "");
8
+ }
9
+ export function getBaseUrl() {
10
+ const raw = process.env.ORCHYN_BASE_URL || DEFAULT_BASE_URL;
11
+ const url = stripTrailingSlash(raw);
12
+ let parsed;
13
+ try {
14
+ parsed = new URL(url);
15
+ }
16
+ catch {
17
+ throw new Error(`Invalid ORCHYN_BASE_URL "${raw}": must be a valid http(s) URL.`);
18
+ }
19
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
20
+ throw new Error(`Invalid ORCHYN_BASE_URL "${raw}": only http and https are supported.`);
21
+ }
22
+ return url;
23
+ }
24
+ export function getPublicUrl() {
25
+ const raw = process.env.ORCHYN_PUBLIC_URL || DEFAULT_PUBLIC_URL;
26
+ const url = stripTrailingSlash(raw);
27
+ try {
28
+ new URL(url);
29
+ }
30
+ catch {
31
+ throw new Error(`Invalid ORCHYN_PUBLIC_URL "${raw}": must be a valid http(s) URL.`);
32
+ }
33
+ return url;
34
+ }
35
+ export function getPort() {
36
+ const raw = process.env.ORCHYN_PORT || String(DEFAULT_PORT);
37
+ const port = Number.parseInt(raw, 10);
38
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
39
+ throw new Error(`Invalid ORCHYN_PORT "${raw}": must be a valid port number.`);
40
+ }
41
+ return port;
42
+ }
43
+ export function getCredentialsFile() {
44
+ if (process.env.ORCHYN_CREDENTIALS_FILE) {
45
+ return process.env.ORCHYN_CREDENTIALS_FILE;
46
+ }
47
+ return path.join(os.homedir(), ".config", "orchyn-mcp", "credentials.json");
48
+ }
49
+ export function getTransportMode() {
50
+ const raw = (process.env.ORCHYN_TRANSPORT || "").toLowerCase();
51
+ if (raw === "http")
52
+ return "http";
53
+ return "stdio";
54
+ }