@nooticr/mcp 1.26.19

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/dist/auth.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Token storage + resolution.
3
+ *
4
+ * Priority: env NOOTICR_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 { NooticrClient, NooticrError } from "./nooticr.js";
10
+ const REFRESH_BEFORE_EXPIRY_MS = 30_000;
11
+ export class NooticrAuthError extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = "NooticrAuthError";
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 NooticrClient(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.NOOTICR_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(`[nooticr-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 NooticrError(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 NooticrAuthError("Not authenticated with nooticr. Run `npx nooticr-mcp login` to sign in " +
153
+ "with Google, or set the NOOTICR_ACCESS_TOKEN environment variable " +
154
+ "(see `npx nooticr-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 = "https://api.nooticr.com";
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.NOOTICR_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 NOOTICR_BASE_URL "${raw}": must be a valid http(s) URL.`);
18
+ }
19
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
20
+ throw new Error(`Invalid NOOTICR_BASE_URL "${raw}": only http and https are supported.`);
21
+ }
22
+ return url;
23
+ }
24
+ export function getPublicUrl() {
25
+ const raw = process.env.NOOTICR_PUBLIC_URL || DEFAULT_PUBLIC_URL;
26
+ const url = stripTrailingSlash(raw);
27
+ try {
28
+ new URL(url);
29
+ }
30
+ catch {
31
+ throw new Error(`Invalid NOOTICR_PUBLIC_URL "${raw}": must be a valid http(s) URL.`);
32
+ }
33
+ return url;
34
+ }
35
+ export function getPort() {
36
+ const raw = process.env.NOOTICR_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 NOOTICR_PORT "${raw}": must be a valid port number.`);
40
+ }
41
+ return port;
42
+ }
43
+ export function getCredentialsFile() {
44
+ if (process.env.NOOTICR_CREDENTIALS_FILE) {
45
+ return process.env.NOOTICR_CREDENTIALS_FILE;
46
+ }
47
+ return path.join(os.homedir(), ".config", "nooticr-mcp", "credentials.json");
48
+ }
49
+ export function getTransportMode() {
50
+ const raw = (process.env.NOOTICR_TRANSPORT || "").toLowerCase();
51
+ if (raw === "http")
52
+ return "http";
53
+ return "stdio";
54
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The stdio package's billing key.
3
+ *
4
+ * Its own module rather than a function in `index.ts` because `index.ts` calls
5
+ * `main()` at module scope: importing it to reach one pure function would
6
+ * start the server, which is not something a test should have to do to check a
7
+ * string.
8
+ */
9
+ import { argumentsDigest } from "./shared/tools.js";
10
+ /**
11
+ * The key the Worker has always sent, which the local package never did.
12
+ *
13
+ * `NooticrClient` documents "a retried tool call is billed once" and the
14
+ * backend implements it — `mcp:<user>:<tool>:<key>` in mcp_tools.rs — but the
15
+ * guarantee only ever reached the hosted connector. The stdio client was
16
+ * constructed without a key, so no `idempotency-key` header was sent at all
17
+ * and a local `npx @nooticr/mcp` user whose call was cut short mid-flight (a
18
+ * dropped pipe, a client that gave up and re-sent) paid for it twice.
19
+ *
20
+ * Same shape as cloudflare/src/endpoint.ts, for the same two reasons: the
21
+ * request id keeps a genuine second call distinct — two identical searches are
22
+ * two searches and bill twice, deliberately — while the arguments digest stops
23
+ * a client that restarts its ids from charging one call for another. There is
24
+ * no session prefix and none is needed: the backend namespaces the reference
25
+ * by user and tool before it ever compares keys, so two people can hold the
26
+ * same string without meeting.
27
+ */
28
+ export function stdioIdempotencyKey(ctx) {
29
+ if (ctx.requestId === undefined)
30
+ return undefined;
31
+ return `stdio:${String(ctx.requestId)}:${argumentsDigest(ctx.arguments)}`;
32
+ }
package/dist/index.js ADDED
@@ -0,0 +1,417 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * nooticr-mcp — MCP server exposing `analyze_post` (analyze any post: video/image/carousel) that runs
4
+ * AI video analysis through the user's nooticr account.
5
+ *
6
+ * Modes:
7
+ * nooticr-mcp stdio transport (default; Claude Desktop, Cursor)
8
+ * nooticr-mcp login browser-based Google sign-in to nooticr
9
+ * nooticr-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 { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
15
+ import { getBaseUrl, getPublicUrl, getPort, getCredentialsFile, getTransportMode, DEFAULT_PORT, } from "./config.js";
16
+ import { AuthManager, NooticrAuthError, createHttpTokenProvider, createStdioTokenProvider } from "./auth.js";
17
+ import { NooticrClient, NooticrError } from "./nooticr.js";
18
+ import { OAuthManager, SCOPES } from "./oauth.js";
19
+ import { createMcpServer } from "./shared/tools.js";
20
+ import { stdioIdempotencyKey } from "./idempotency.js";
21
+ import { FileWatchStore } from "./shared/watchlist.js";
22
+ import path from "node:path";
23
+ /**
24
+ * The watchlist lives beside the credentials: one machine, one signed-in user,
25
+ * and it has to outlive the process or "since I last looked" means nothing.
26
+ */
27
+ function watchStoreForStdio() {
28
+ return new FileWatchStore(path.join(path.dirname(getCredentialsFile()), "watchlist.json"));
29
+ }
30
+ // ---------------------------------------------------------------------------
31
+ // stdio mode
32
+ // ---------------------------------------------------------------------------
33
+ export async function runStdio() {
34
+ const auth = new AuthManager(getBaseUrl(), getCredentialsFile());
35
+ const server = createMcpServer((ctx) => new NooticrClient(getBaseUrl(), createStdioTokenProvider(auth), stdioIdempotencyKey(ctx)), { localWatchStore: watchStoreForStdio() });
36
+ const transport = new StdioServerTransport();
37
+ await server.connect(transport);
38
+ // Keep the process alive until the transport closes (handled by the SDK).
39
+ }
40
+ function sendJson(res, status, body) {
41
+ const payload = JSON.stringify(body);
42
+ res.writeHead(status, {
43
+ "content-type": "application/json",
44
+ "content-length": Buffer.byteLength(payload),
45
+ "cache-control": "no-store",
46
+ });
47
+ res.end(payload);
48
+ }
49
+ function readJsonBody(req) {
50
+ return new Promise((resolve, reject) => {
51
+ const chunks = [];
52
+ req.on("data", (c) => chunks.push(Buffer.from(c)));
53
+ req.on("end", () => {
54
+ if (chunks.length === 0)
55
+ return resolve(undefined);
56
+ const text = Buffer.concat(chunks).toString("utf8");
57
+ try {
58
+ resolve(JSON.parse(text));
59
+ }
60
+ catch {
61
+ resolve(undefined);
62
+ }
63
+ });
64
+ req.on("error", reject);
65
+ });
66
+ }
67
+ function sendHtml(res, status, body) {
68
+ const payload = `<!doctype html><html><head><meta charset="utf-8"><title>nooticr-mcp</title></head><body>${body}</body></html>`;
69
+ res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
70
+ res.end(payload);
71
+ }
72
+ function bearerToken(req) {
73
+ const header = req.headers.authorization;
74
+ if (!header)
75
+ return undefined;
76
+ const match = /^Bearer\s+(.+)$/i.exec(header);
77
+ return match ? match[1].trim() : undefined;
78
+ }
79
+ /**
80
+ * A request bearer token is valid when it was issued by our OAuth `/token`
81
+ * endpoint, or when it matches `NOOTICR_ACCESS_TOKEN` (pre-provisioned
82
+ * deployments and local testing — no browser OAuth round-trip needed).
83
+ */
84
+ function validMcpToken(token, oauth) {
85
+ if (oauth.verifyToken(token))
86
+ return true;
87
+ const envToken = process.env.NOOTICR_ACCESS_TOKEN;
88
+ return typeof envToken === "string" && envToken.length > 0 && token === envToken;
89
+ }
90
+ async function handleMcpRequest(state, req, res) {
91
+ const token = bearerToken(req);
92
+ const session = token ? state.oauth.verifyToken(token) : undefined;
93
+ if (!token || !validMcpToken(token, state.oauth)) {
94
+ return sendJson(res, 401, {
95
+ error: "Unauthorized",
96
+ error_description: "This MCP server requires OAuth authentication. Fetch an access token from " +
97
+ `${state.oauth.authorizationServerMetadata().authorization_endpoint} first.`,
98
+ });
99
+ }
100
+ // Hand the validated token to the SDK transport, which forwards it to tool
101
+ // handlers via RequestHandlerExtra.authInfo (see shared/protocol.js).
102
+ req.auth = {
103
+ token,
104
+ clientId: session?.clientId ?? "nooticr-mcp",
105
+ scopes: session?.scopes ?? [...SCOPES],
106
+ expiresAt: session
107
+ ? Math.floor(session.expiresAt / 1000)
108
+ : Math.floor(Date.now() / 1000) + 3600,
109
+ };
110
+ const sessionId = req.headers["mcp-session-id"] ?? "";
111
+ let transport;
112
+ let parsedBody;
113
+ if (sessionId) {
114
+ transport = state.transports.get(sessionId);
115
+ }
116
+ else {
117
+ // No session id: an "initialize" request always starts a fresh session.
118
+ // Any other request is routed to the sole active session's transport
119
+ // (covers naive clients that never send the session id).
120
+ let isInitialize = false;
121
+ if (req.method === "POST") {
122
+ parsedBody = await readJsonBody(req);
123
+ const messages = Array.isArray(parsedBody) ? parsedBody : [parsedBody];
124
+ isInitialize = messages.some((m) => m !== null &&
125
+ typeof m === "object" &&
126
+ m.method === "initialize");
127
+ }
128
+ if (!isInitialize && state.transports.size === 1) {
129
+ transport = state.transports.values().next().value;
130
+ if (transport.sessionId) {
131
+ // The SDK's stateful transport requires the header on non-initialize
132
+ // requests; hono's node adapter reads req.rawHeaders, so patch both.
133
+ req.rawHeaders.push("mcp-session-id", transport.sessionId);
134
+ req.headers["mcp-session-id"] =
135
+ transport.sessionId;
136
+ }
137
+ }
138
+ }
139
+ if (!transport) {
140
+ transport = new StreamableHTTPServerTransport({
141
+ sessionIdGenerator: () => randomUUID(),
142
+ enableJsonResponse: true,
143
+ onsessioninitialized: (sid) => {
144
+ state.transports.set(sid, transport);
145
+ },
146
+ });
147
+ // The SDK's McpServer can only attach to one transport, so each MCP
148
+ // session gets its own server instance (tools are registered per
149
+ // instance; sessions resolve their nooticr identity via authInfo).
150
+ const mcpserver = state.serverFactory();
151
+ state.connections.set(transport, mcpserver);
152
+ transport.onclose = () => {
153
+ state.connections.delete(transport);
154
+ if (transport?.sessionId)
155
+ state.transports.delete(transport.sessionId);
156
+ };
157
+ await mcpserver.connect(transport);
158
+ }
159
+ try {
160
+ // If we already consumed the body to sniff for "initialize", hand the
161
+ // parsed payload to the transport so it doesn't try to re-read it.
162
+ if (parsedBody !== undefined) {
163
+ await transport.handleRequest(req, res, parsedBody);
164
+ }
165
+ else {
166
+ await transport.handleRequest(req, res);
167
+ }
168
+ }
169
+ catch (err) {
170
+ const msg = err instanceof Error ? err.message : String(err);
171
+ if (!res.headersSent) {
172
+ sendJson(res, 500, { error: `Internal error: ${msg}` });
173
+ }
174
+ }
175
+ }
176
+ async function handleHttpRequest(state, req, res) {
177
+ const url = new URL(req.url ?? "/", "http://localhost");
178
+ const pathname = url.pathname;
179
+ if (pathname === "/.well-known/oauth-authorization-server" && req.method === "GET") {
180
+ return sendJson(res, 200, state.oauth.authorizationServerMetadata());
181
+ }
182
+ if (pathname === "/.well-known/oauth-protected-resource" && req.method === "GET") {
183
+ return sendJson(res, 200, state.oauth.protectedResourceMetadata());
184
+ }
185
+ if (pathname === "/authorize" && req.method === "GET") {
186
+ return state.oauth.handleAuthorize(req, res);
187
+ }
188
+ if (pathname === "/token" && req.method === "POST") {
189
+ return state.oauth.handleToken(req, res);
190
+ }
191
+ if (pathname === "/oauth/callback" && req.method === "GET") {
192
+ return state.oauth.handleCallback(req, res);
193
+ }
194
+ if (pathname === "/" && req.method === "GET") {
195
+ const meta = state.oauth.authorizationServerMetadata();
196
+ return sendHtml(res, 200, `<h1>nooticr-mcp</h1><p>MCP server is running.</p>` +
197
+ `<p>Authorization endpoint: <code>${meta.authorization_endpoint}</code></p>` +
198
+ `<p>Token endpoint: <code>${meta.token_endpoint}</code></p>`);
199
+ }
200
+ if (pathname === "/mcp" || pathname === "/") {
201
+ return handleMcpRequest(state, req, res);
202
+ }
203
+ return sendJson(res, 404, { error: "Not found" });
204
+ }
205
+ export async function runHttp(port, publicUrl) {
206
+ const baseUrl = getBaseUrl();
207
+ const pub = publicUrl ?? getPublicUrl();
208
+ const auth = new AuthManager(baseUrl, getCredentialsFile());
209
+ const oauth = new OAuthManager({
210
+ publicUrl: pub,
211
+ client: new NooticrClient(baseUrl, {
212
+ getAccessToken: async () => undefined,
213
+ }),
214
+ onSession: async (session) => {
215
+ await auth.persistSession(session);
216
+ },
217
+ });
218
+ const state = {
219
+ oauth,
220
+ auth,
221
+ transports: new Map(),
222
+ connections: new Map(),
223
+ serverFactory: () => createMcpServer((extra) => {
224
+ const session = extra.authInfo?.token
225
+ ? oauth.verifyToken(extra.authInfo.token)
226
+ : undefined;
227
+ return new NooticrClient(baseUrl, createHttpTokenProvider(auth, session
228
+ ? {
229
+ accessToken: session.nooticrAccessToken,
230
+ refreshToken: session.nooticrRefreshToken,
231
+ }
232
+ : undefined));
233
+ }),
234
+ };
235
+ const server = http.createServer((req, res) => {
236
+ handleHttpRequest(state, req, res).catch((err) => {
237
+ const msg = err instanceof Error ? err.message : String(err);
238
+ if (!res.headersSent)
239
+ sendJson(res, 500, { error: `Internal error: ${msg}` });
240
+ else
241
+ res.end();
242
+ });
243
+ });
244
+ server.listen(port, () => {
245
+ process.stdout.write(`[nooticr-mcp] HTTP server listening on ${pub.replace(/:\d+$/, "")}:${port}\n` +
246
+ `[nooticr-mcp] OAuth metadata: ${pub}/.well-known/oauth-authorization-server\n` +
247
+ `[nooticr-mcp] MCP endpoint: ${pub}/mcp\n`);
248
+ });
249
+ }
250
+ export async function runLogin(opts) {
251
+ const baseUrl = getBaseUrl();
252
+ const auth = new AuthManager(baseUrl, getCredentialsFile());
253
+ const client = new NooticrClient(baseUrl, {
254
+ getAccessToken: async () => undefined,
255
+ });
256
+ if (opts.email && opts.password) {
257
+ const session = await client.login(opts.email, opts.password);
258
+ await auth.persistSession(session);
259
+ process.stdout.write(`Signed in as ${session.user?.email ?? opts.email}. Credentials saved to ${auth.getCredentialsFile()}\n`);
260
+ return;
261
+ }
262
+ // Browser flow: open the nooticr-branded login page (Google + email/password)
263
+ // at /auth/mcp-login?redirect=<loopback callback>. Both paths converge on a
264
+ // single ?code= redirect the listener exchanges for JWTs.
265
+ const callbackPath = "/oauth/callback";
266
+ const callbackUrl = `http://127.0.0.1:${opts.port}${callbackPath}`;
267
+ const mcpLoginUrl = new URL("/auth/mcp-login", baseUrl);
268
+ mcpLoginUrl.searchParams.set("redirect", callbackUrl);
269
+ const listener = http.createServer((req, res) => {
270
+ const reqUrl = new URL(req.url ?? "/", "http://127.0.0.1");
271
+ if (reqUrl.pathname === callbackPath && req.method === "GET") {
272
+ const code = reqUrl.searchParams.get("code") ?? "";
273
+ if (code) {
274
+ client
275
+ .exchangeCompletionCode(code)
276
+ .then(async (session) => {
277
+ await auth.persistSession(session);
278
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
279
+ res.end("<html><body><h1>Signed in!</h1><p>You can close this tab and return to your terminal.</p></body></html>");
280
+ process.stdout.write(`Signed in as ${session.user?.email ?? "unknown user"}. Credentials saved to ${auth.getCredentialsFile()}\n`);
281
+ process.exit(0);
282
+ })
283
+ .catch((err) => {
284
+ const msg = err instanceof Error ? err.message : String(err);
285
+ res.writeHead(502, { "content-type": "text/html; charset=utf-8" });
286
+ res.end(`<html><body><h1>Sign-in failed</h1><p>${msg}</p></body></html>`);
287
+ process.stderr.write(`Sign-in failed: ${msg}\n`);
288
+ process.exit(1);
289
+ });
290
+ }
291
+ else {
292
+ res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
293
+ res.end("<html><body><h1>Sign-in failed</h1><p>No code returned.</p></body></html>");
294
+ process.exit(1);
295
+ }
296
+ return;
297
+ }
298
+ res.writeHead(404).end();
299
+ });
300
+ await new Promise((resolve, reject) => {
301
+ listener.once("error", reject);
302
+ listener.listen(opts.port, "127.0.0.1", resolve);
303
+ });
304
+ process.stdout.write(`Open this URL in your browser to sign in with your nooticr account:\n\n ${mcpLoginUrl.toString()}\n\n`);
305
+ try {
306
+ const { default: open } = await import("open");
307
+ await open(mcpLoginUrl.toString());
308
+ }
309
+ catch {
310
+ process.stdout.write("Could not open the browser automatically. Copy the URL above into your browser.\n");
311
+ }
312
+ // Time out after 5 minutes.
313
+ setTimeout(() => {
314
+ process.stderr.write("Timed out waiting for sign-in.\n");
315
+ process.exit(1);
316
+ }, 300_000).unref();
317
+ }
318
+ // ---------------------------------------------------------------------------
319
+ // CLI
320
+ // ---------------------------------------------------------------------------
321
+ function printHelp() {
322
+ process.stdout.write(`nooticr-mcp — MCP server for nooticr AI video analysis
323
+
324
+ Usage:
325
+ nooticr-mcp Start in stdio mode (default transport)
326
+ nooticr-mcp --stdio Same as above
327
+ nooticr-mcp --http [--port N] Start the remote HTTP transport with OAuth
328
+ (default port ${DEFAULT_PORT}; also NOOTICR_PORT)
329
+ nooticr-mcp login Sign in to nooticr via Google in your browser
330
+ nooticr-mcp login --email me@example.com --password '...' Password login
331
+ nooticr-mcp --help Show this help
332
+
333
+ Environment variables:
334
+ NOOTICR_BASE_URL nooticr server base URL (default http://localhost:8080)
335
+ NOOTICR_ACCESS_TOKEN nooticr JWT access token (bypasses login)
336
+ NOOTICR_CREDENTIALS_FILE token store path (default ~/.config/nooticr-mcp/credentials.json)
337
+ NOOTICR_PUBLIC_URL public base URL for the HTTP mode (default http://localhost:3457)
338
+ NOOTICR_PORT port for --http and login (default ${DEFAULT_PORT})
339
+ NOOTICR_TRANSPORT "stdio" or "http"
340
+
341
+ Client setup:
342
+ Claude Desktop / Cursor (stdio): after "nooticr-mcp login", use
343
+ "command": "npx", "args": ["nooticr-mcp"] (plus NOOTICR_ACCESS_TOKEN if needed)
344
+ OpenAI Agents SDK (remote HTTP): use the RemoteMCPClient with URL
345
+ <NOOTICR_PUBLIC_URL>/mcp — the OAuth flow will open your browser.
346
+
347
+ See README.md for full instructions.
348
+ `);
349
+ }
350
+ async function main() {
351
+ const args = process.argv.slice(2);
352
+ if (args.includes("--help") || args.includes("-h")) {
353
+ printHelp();
354
+ return;
355
+ }
356
+ if (args[0] === "login") {
357
+ const rest = args.slice(1);
358
+ const valueOf = (flag) => {
359
+ const idx = rest.indexOf(flag);
360
+ return idx >= 0 && rest[idx + 1] ? rest[idx + 1] : undefined;
361
+ };
362
+ const email = valueOf("--email");
363
+ const password = valueOf("--password");
364
+ let port = getPort();
365
+ const portRaw = valueOf("--port");
366
+ if (portRaw !== undefined)
367
+ port = Number.parseInt(portRaw, 10);
368
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
369
+ process.stderr.write("Invalid --port value.\n");
370
+ process.exit(1);
371
+ }
372
+ if (Boolean(email) !== Boolean(password)) {
373
+ process.stderr.write("Both --email and --password must be provided together.\n");
374
+ process.exit(1);
375
+ }
376
+ try {
377
+ await runLogin({ email, password, port });
378
+ }
379
+ catch (err) {
380
+ process.stderr.write(`Login failed: ${err instanceof Error ? err.message : String(err)}\n`);
381
+ process.exit(1);
382
+ }
383
+ return;
384
+ }
385
+ const isHttp = args.includes("--http") || getTransportMode() === "http";
386
+ let port = getPort();
387
+ if (args.includes("--port")) {
388
+ const idx = args.indexOf("--port");
389
+ if (args[idx + 1])
390
+ port = Number.parseInt(args[idx + 1], 10);
391
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
392
+ process.stderr.write("Invalid --port value.\n");
393
+ process.exit(1);
394
+ }
395
+ }
396
+ try {
397
+ if (isHttp) {
398
+ await runHttp(port);
399
+ }
400
+ else {
401
+ await runStdio();
402
+ }
403
+ }
404
+ catch (err) {
405
+ if (err instanceof NooticrAuthError) {
406
+ process.stderr.write(`${err.message}\n`);
407
+ }
408
+ else if (err instanceof NooticrError) {
409
+ process.stderr.write(`nooticr API error: ${err.message}\n`);
410
+ }
411
+ else {
412
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
413
+ }
414
+ process.exit(1);
415
+ }
416
+ }
417
+ main();
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The nooticr API client now lives in `shared/nooticr.ts` so the Node package
3
+ * and the Cloudflare Worker import one implementation. This file re-exports
4
+ * it for backward compatibility.
5
+ */
6
+ export * from "./shared/nooticr.js";