@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.
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Runtime-agnostic nooticr API client — the single source of truth for the
3
+ * Node package (`@nooticr/mcp`) and the Cloudflare Worker (mcp.nooticr.com).
4
+ *
5
+ * Only Web-standard APIs are used (fetch, crypto, atob), so both runtimes
6
+ * import this same module: no duplicated client logic to keep in sync. Token
7
+ * storage lives behind the `TokenProvider` interface — a credentials file for
8
+ * the Node CLI, a KV-backed session for the Worker.
9
+ */
10
+ export class NooticrError extends Error {
11
+ status;
12
+ code;
13
+ paywall;
14
+ body;
15
+ constructor(status, message, opts = {}) {
16
+ super(message);
17
+ this.name = "NooticrError";
18
+ this.status = status;
19
+ this.code = opts.code;
20
+ this.paywall = opts.paywall;
21
+ this.body = opts.body;
22
+ }
23
+ }
24
+ /**
25
+ * Extracts the `exp` claim (epoch seconds) from an nooticr JWT without
26
+ * verifying the signature — enough to decide whether to refresh before the
27
+ * 15-minute access token expires.
28
+ */
29
+ export function jwtExpiry(token) {
30
+ try {
31
+ const payload = token.split(".")[1];
32
+ if (!payload)
33
+ return undefined;
34
+ const pad = payload.length % 4 === 0 ? "" : "=".repeat(4 - (payload.length % 4));
35
+ const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/") + pad);
36
+ const claims = JSON.parse(json);
37
+ return typeof claims.exp === "number" ? claims.exp : undefined;
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ export class NooticrClient {
44
+ baseUrl;
45
+ tokenProvider;
46
+ /** Stable per-call key so a retried tool call is billed once. */
47
+ idempotencyKey;
48
+ constructor(baseUrl, tokenProvider, idempotencyKey) {
49
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
50
+ this.tokenProvider = tokenProvider;
51
+ this.idempotencyKey = idempotencyKey;
52
+ }
53
+ async request(method, path, opts = {}) {
54
+ const doRequest = async (accessToken) => {
55
+ const headers = {};
56
+ if (opts.body !== undefined) {
57
+ headers["content-type"] = "application/json";
58
+ }
59
+ // Billed calls carry the key so the backend charges a retry once. A
60
+ // redeploy or dropped stream mid-call leaves the client with no result,
61
+ // and its retry must not cost the user a second time.
62
+ if (opts.idempotent && this.idempotencyKey) {
63
+ headers["idempotency-key"] = this.idempotencyKey;
64
+ }
65
+ if (accessToken) {
66
+ headers.authorization = `Bearer ${accessToken}`;
67
+ }
68
+ return fetch(`${this.baseUrl}${path}`, {
69
+ method,
70
+ headers,
71
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
72
+ });
73
+ };
74
+ let token = opts.token;
75
+ if (opts.auth && !token) {
76
+ token = await this.tokenProvider.getAccessToken();
77
+ }
78
+ if (opts.auth && !token) {
79
+ throw new NooticrError(401, "No nooticr access token available.");
80
+ }
81
+ let res = await doRequest(token);
82
+ if (res.status === 401 && opts.auth && this.tokenProvider.onUnauthorized) {
83
+ const refreshed = await this.tokenProvider.onUnauthorized();
84
+ if (refreshed) {
85
+ token = await this.tokenProvider.getAccessToken();
86
+ res = await doRequest(token);
87
+ }
88
+ }
89
+ return this.normalizeResponse(res, path);
90
+ }
91
+ async normalizeResponse(res, path) {
92
+ const text = await res.text();
93
+ let body;
94
+ try {
95
+ body = text ? JSON.parse(text) : undefined;
96
+ }
97
+ catch {
98
+ body = undefined;
99
+ }
100
+ const json = (body ?? {});
101
+ const errorMessage = typeof json.error === "string"
102
+ ? json.error
103
+ : // Name the endpoint: a bare status code says nothing about which
104
+ // call failed, and these surface to the user as tool errors.
105
+ `nooticr API error (${res.status}) from ${path}`;
106
+ if (res.status >= 200 && res.status < 300) {
107
+ return body;
108
+ }
109
+ if (res.status === 402) {
110
+ throw new NooticrError(402, errorMessage, {
111
+ paywall: {
112
+ reason: typeof json.reason === "string" ? json.reason : undefined,
113
+ used: typeof json.used === "number" ? json.used : undefined,
114
+ max: typeof json.max === "number" ? json.max : undefined,
115
+ cost: typeof json.cost === "number" ? json.cost : undefined,
116
+ },
117
+ body,
118
+ });
119
+ }
120
+ throw new NooticrError(res.status, errorMessage, {
121
+ code: typeof json.code === "string" ? json.code : undefined,
122
+ body,
123
+ });
124
+ }
125
+ async startVideoAnalysis(url, appId) {
126
+ const res = await this.request("POST", "/mcp/analyze-post", {
127
+ auth: true,
128
+ body: appId !== undefined ? { url, appId } : { url },
129
+ });
130
+ const inline = Array.isArray(res?._inlineImages)
131
+ ? res._inlineImages
132
+ : undefined;
133
+ const job = res;
134
+ return { ...job, inlineImages: inline };
135
+ }
136
+ /**
137
+ * Proxies a generic nooticr backend MCP tool (`get_social_media`,
138
+ * `discover_social_videos`, `understand_social_post`, …) through
139
+ * `POST /mcp` JSON-RPC. The backend enforces per-user credit billing;
140
+ * tool-level failures surface as NooticrError with the backend message.
141
+ */
142
+ async callTool(name, args) {
143
+ const rpc = await this.request("POST", "/mcp", {
144
+ auth: true,
145
+ idempotent: true,
146
+ body: {
147
+ jsonrpc: "2.0",
148
+ id: Date.now(),
149
+ method: "tools/call",
150
+ params: { name, arguments: args },
151
+ },
152
+ });
153
+ if (rpc && typeof rpc === "object" && rpc.error !== undefined && rpc.error !== null) {
154
+ const err = rpc.error;
155
+ throw new NooticrError(err.code === -32002 ? 402 : 400, typeof err.message === "string" ? err.message : "nooticr MCP tool call failed");
156
+ }
157
+ const result = (rpc.result ?? {});
158
+ if (result.isError) {
159
+ const text = result.content
160
+ ?.filter((c) => c.type === "text")
161
+ .map((c) => String(c.text ?? ""))
162
+ .join("\n");
163
+ throw new NooticrError(400, text || "nooticr MCP tool call failed");
164
+ }
165
+ return {
166
+ contentBlocks: result.content ?? [],
167
+ structured: result.structuredContent,
168
+ };
169
+ }
170
+ async getJob(jobId) {
171
+ return this.request("GET", `/ai/analyze-post?jobId=${encodeURIComponent(jobId)}`, {
172
+ auth: true,
173
+ });
174
+ }
175
+ async me() {
176
+ return this.request("GET", "/auth/me", { auth: true });
177
+ }
178
+ async exchangeCompletionCode(code, workspaceId) {
179
+ return NooticrClient.exchangeCode(this.baseUrl, code, workspaceId);
180
+ }
181
+ /** Starts a Google sign-in for the given redirect URL; returns the Google redirectUrl. */
182
+ async startGoogleSignIn(redirect) {
183
+ return this.request("POST", "/auth/google/start", {
184
+ auth: false,
185
+ body: { redirect },
186
+ });
187
+ }
188
+ async login(email, password) {
189
+ return NooticrClient.login(this.baseUrl, email, password);
190
+ }
191
+ async refresh(refreshToken) {
192
+ return NooticrClient.refreshSession(this.baseUrl, refreshToken);
193
+ }
194
+ static async login(baseUrl, email, password) {
195
+ const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/auth/login`, {
196
+ method: "POST",
197
+ headers: { "content-type": "application/json" },
198
+ body: JSON.stringify({ email, password }),
199
+ });
200
+ const body = await parseJsonBody(res);
201
+ if (res.status >= 200 && res.status < 300) {
202
+ return body;
203
+ }
204
+ throw new NooticrError(res.status, typeof body?.error === "string"
205
+ ? body.error
206
+ : `Login failed (${res.status})`, { body });
207
+ }
208
+ /** Exchanges an nooticr one-time completion code (MCP login flow) for a session. */
209
+ static async exchangeCode(baseUrl, code, workspaceId) {
210
+ const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/auth/oauth/complete`, {
211
+ method: "POST",
212
+ headers: { "content-type": "application/json" },
213
+ body: JSON.stringify(workspaceId !== undefined ? { code, workspaceId } : { code }),
214
+ });
215
+ const body = await parseJsonBody(res);
216
+ if (res.status >= 200 && res.status < 300) {
217
+ return body;
218
+ }
219
+ throw new NooticrError(res.status, typeof body?.error === "string"
220
+ ? body.error
221
+ : `Code exchange failed (${res.status})`, { body });
222
+ }
223
+ /** Redeems a refresh token for a fresh nooticr session (rotating the refresh token). */
224
+ static async refreshSession(baseUrl, refreshToken) {
225
+ const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/auth/refresh`, {
226
+ method: "POST",
227
+ headers: { "content-type": "application/json" },
228
+ body: JSON.stringify({ refreshToken }),
229
+ });
230
+ const body = await parseJsonBody(res);
231
+ const json = (body ?? {});
232
+ if (res.status >= 200 && res.status < 300) {
233
+ if (!json || typeof json.accessToken !== "string") {
234
+ throw new NooticrError(500, "Refresh succeeded but returned no access token.");
235
+ }
236
+ return body;
237
+ }
238
+ throw new NooticrError(res.status, typeof json.error === "string" ? json.error : `Refresh failed (${res.status})`, { body });
239
+ }
240
+ }
241
+ async function parseJsonBody(res) {
242
+ const text = await res.text();
243
+ try {
244
+ return text ? JSON.parse(text) : undefined;
245
+ }
246
+ catch {
247
+ return undefined;
248
+ }
249
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Runtime-agnostic OAuth 2.0 / PKCE primitives shared by the Node package
3
+ * (`@nooticr/mcp`) and the Cloudflare Worker (mcp.nooticr.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
+ /**
8
+ * Scopes this server grants.
9
+ *
10
+ * These describe what access a client is actually asking for, so a reviewer
11
+ * (or a user on the consent screen) can map them to the tool list:
12
+ * social:read — read public posts, transcripts, comments, creators,
13
+ * sounds and hashtags on the supported networks
14
+ * credits:spend — run the AI tools and open a checkout, both of which
15
+ * draw on the account's credit balance
16
+ *
17
+ * The server previously advertised a single `analyze:video`, which described
18
+ * about a fifth of what the tools do. Scopes are carried on the session but
19
+ * never used to reject a call, so tokens issued under the old value keep
20
+ * working; LEGACY_SCOPE is kept so that stays deliberate rather than
21
+ * accidental.
22
+ */
23
+ export const SCOPES = ["social:read", "credits:spend"];
24
+ /** Space-delimited form, for an `authorize` request. */
25
+ export const SCOPE = SCOPES.join(" ");
26
+ /** Issued before the scopes above existed. Still honoured. */
27
+ export const LEGACY_SCOPE = "analyze:video";
28
+ /**
29
+ * The scopes an authorization request may ask for, split out of the request's
30
+ * space-delimited `scope` parameter.
31
+ *
32
+ * Shared because the worker and the node package each validated this
33
+ * separately and drifted: the worker compared every individual scope against
34
+ * the joined string, so once there was more than one scope nothing could ever
35
+ * match and every authorization was refused with invalid_scope.
36
+ *
37
+ * Returns the scopes that are not recognised — empty means the request is fine.
38
+ * LEGACY_SCOPE is accepted alongside the current ones: clients connected
39
+ * before the split still ask for it, and refusing it would break every
40
+ * existing installation on upgrade.
41
+ */
42
+ export function parseScopes(scope) {
43
+ return (scope ?? "").split(/\s+/).filter(Boolean);
44
+ }
45
+ export function unsupportedScopes(scope) {
46
+ const accepted = new Set([...SCOPES, LEGACY_SCOPE]);
47
+ return parseScopes(scope).filter((s) => !accepted.has(s));
48
+ }
49
+ export function isLoopbackUrl(url) {
50
+ let parsed;
51
+ try {
52
+ parsed = new URL(url);
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ if (parsed.protocol !== "http:")
58
+ return false;
59
+ const host = parsed.hostname;
60
+ return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1";
61
+ }
62
+ /** redirect_uri must be loopback (http://localhost|127.0.0.1|[::1]) or any https URL. */
63
+ export function isAllowedRedirectUri(uri) {
64
+ let parsed;
65
+ try {
66
+ parsed = new URL(uri);
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ if (parsed.protocol === "https:")
72
+ return true;
73
+ if (parsed.protocol === "http:" && isLoopbackUrl(uri))
74
+ return true;
75
+ return false;
76
+ }
77
+ /** RFC 7636 S256 PKCE check (SHA-256 + base64url compare). */
78
+ export async function verifyPkce(codeVerifier, codeChallenge) {
79
+ if (!codeVerifier || !codeChallenge)
80
+ return false;
81
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
82
+ const bytes = new Uint8Array(digest);
83
+ let s = "";
84
+ for (const b of bytes)
85
+ s += String.fromCharCode(b);
86
+ const b64 = btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
87
+ return b64 === codeChallenge;
88
+ }
89
+ export function randomToken(bytes = 32) {
90
+ const buf = new Uint8Array(bytes);
91
+ crypto.getRandomValues(buf);
92
+ let s = "";
93
+ for (const b of buf)
94
+ s += String.fromCharCode(b);
95
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
96
+ }
97
+ export function authorizationServerMetadata(publicUrl, opts = {}) {
98
+ return {
99
+ issuer: publicUrl,
100
+ authorization_endpoint: `${publicUrl}/authorize`,
101
+ token_endpoint: `${publicUrl}/token`,
102
+ // Clients try CIMD before DCR when both are on offer, which is the point:
103
+ // Dynamic Client Registration is deprecated (MCP 2026-07-28), and without
104
+ // this flag every client fell through to it. The registration endpoint
105
+ // stays for clients that only know the old way.
106
+ client_id_metadata_document_supported: true,
107
+ ...(opts.registration
108
+ ? { registration_endpoint: `${publicUrl}/register` }
109
+ : {}),
110
+ response_types_supported: ["code"],
111
+ code_challenge_methods_supported: ["S256"],
112
+ token_endpoint_auth_methods_supported: ["none"],
113
+ scopes_supported: [...SCOPES],
114
+ grant_types_supported: ["authorization_code"],
115
+ };
116
+ }
117
+ export function protectedResourceMetadata(publicUrl) {
118
+ return {
119
+ resource: `${publicUrl}/mcp`,
120
+ authorization_servers: [publicUrl],
121
+ };
122
+ }
123
+ export function escapeHtml(input) {
124
+ return input
125
+ .replace(/&/g, "&amp;")
126
+ .replace(/</g, "&lt;")
127
+ .replace(/>/g, "&gt;")
128
+ .replace(/"/g, "&quot;");
129
+ }
130
+ export function generateState() {
131
+ return crypto.randomUUID();
132
+ }