@neopress/mcp 1.3.0 → 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/dist/index.js CHANGED
@@ -3,9 +3,11 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { homedir } from "node:os";
6
+ import { lookup } from "node:dns/promises";
7
+ import { isIP } from "node:net";
6
8
  import { createServer } from "node:http";
7
9
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8
- import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
10
+ import { Redis } from "@upstash/redis";
9
11
  var __defProp = Object.defineProperty;
10
12
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
11
13
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -22809,7 +22811,7 @@ function shouldShowDeprecationWarning() {
22809
22811
  }
22810
22812
  if (shouldShowDeprecationWarning()) console.warn("⚠️ Node.js 18 and below are deprecated and will no longer be supported in future versions of @supabase/supabase-js. Please upgrade to Node.js 20 or later. For more information, visit: https://github.com/orgs/supabase/discussions/37217");
22811
22813
  //#endregion
22812
- //#region ../sdk/dist/errors.js
22814
+ //#region ../sdk/src/errors.ts
22813
22815
  /**
22814
22816
  * SDK error classes
22815
22817
  */
@@ -22826,13 +22828,7 @@ var NeopressApiError = class extends Error {
22826
22828
  }
22827
22829
  };
22828
22830
  //#endregion
22829
- //#region ../sdk/dist/serializers.js
22830
- /**
22831
- * SDK serializers — convert DB snake_case rows to SDK camelCase shapes.
22832
- *
22833
- * Ported 1:1 from apps/neopress/src/lib/api/v1/serializers.ts.
22834
- * Do NOT change field mappings without updating both files.
22835
- */
22831
+ //#region ../sdk/src/serializers.ts
22836
22832
  function serializePageFull(page) {
22837
22833
  return {
22838
22834
  id: page.id,
@@ -22993,6 +22989,7 @@ function serializeTemplate(t) {
22993
22989
  referenceUrl: t.reference_url,
22994
22990
  previewImgUrls: t.preview_img_urls,
22995
22991
  previewVideoUrl: t.preview_video_url ?? null,
22992
+ language: t.language ?? null,
22996
22993
  createdAt: t.created_at
22997
22994
  };
22998
22995
  }
@@ -23081,7 +23078,7 @@ function serializeSiteLayout(siteId, layout) {
23081
23078
  };
23082
23079
  }
23083
23080
  //#endregion
23084
- //#region ../sdk/dist/endpoints/pages.js
23081
+ //#region ../sdk/src/endpoints/pages.ts
23085
23082
  var PagesEndpoint = class {
23086
23083
  client;
23087
23084
  constructor(client) {
@@ -23420,7 +23417,7 @@ function getDefaultLogLevel() {
23420
23417
  }
23421
23418
  getDefaultLogLevel();
23422
23419
  //#endregion
23423
- //#region ../sdk/dist/endpoints/collections.js
23420
+ //#region ../sdk/src/endpoints/collections.ts
23424
23421
  var CollectionsEndpoint = class {
23425
23422
  client;
23426
23423
  constructor(client) {
@@ -23486,7 +23483,7 @@ var CollectionsEndpoint = class {
23486
23483
  }
23487
23484
  };
23488
23485
  //#endregion
23489
- //#region ../sdk/dist/endpoints/entries.js
23486
+ //#region ../sdk/src/endpoints/entries.ts
23490
23487
  var EntriesEndpoint = class {
23491
23488
  client;
23492
23489
  constructor(client) {
@@ -23559,7 +23556,7 @@ var EntriesEndpoint = class {
23559
23556
  }
23560
23557
  };
23561
23558
  //#endregion
23562
- //#region ../sdk/dist/endpoints/forms.js
23559
+ //#region ../sdk/src/endpoints/forms.ts
23563
23560
  var FormsEndpoint = class {
23564
23561
  client;
23565
23562
  constructor(client) {
@@ -23627,7 +23624,7 @@ var FormsEndpoint = class {
23627
23624
  }
23628
23625
  };
23629
23626
  //#endregion
23630
- //#region ../sdk/dist/endpoints/assets.js
23627
+ //#region ../sdk/src/endpoints/assets.ts
23631
23628
  var AssetsEndpoint = class {
23632
23629
  client;
23633
23630
  constructor(client) {
@@ -23688,7 +23685,7 @@ var AssetsEndpoint = class {
23688
23685
  }
23689
23686
  };
23690
23687
  //#endregion
23691
- //#region ../sdk/dist/endpoints/site.js
23688
+ //#region ../sdk/src/endpoints/site.ts
23692
23689
  var SiteEndpoint = class {
23693
23690
  client;
23694
23691
  constructor(client) {
@@ -23733,7 +23730,7 @@ var SiteEndpoint = class {
23733
23730
  }
23734
23731
  };
23735
23732
  //#endregion
23736
- //#region ../sdk/dist/endpoints/redirects.js
23733
+ //#region ../sdk/src/endpoints/redirects.ts
23737
23734
  const DASHBOARD_ONLY_REWRITE_MESSAGE = "Rewrite proxy rules can only be managed in the dashboard.";
23738
23735
  function rejectModeParam(data) {
23739
23736
  if (data && typeof data === "object" && Object.prototype.hasOwnProperty.call(data, "mode")) throw new NeopressApiError("REDIRECT_RULE_DASHBOARD_ONLY", DASHBOARD_ONLY_REWRITE_MESSAGE, 400);
@@ -23794,7 +23791,7 @@ var RedirectsEndpoint = class {
23794
23791
  }
23795
23792
  };
23796
23793
  //#endregion
23797
- //#region ../sdk/dist/endpoints/publish.js
23794
+ //#region ../sdk/src/endpoints/publish.ts
23798
23795
  var PublishEndpoint = class {
23799
23796
  client;
23800
23797
  constructor(client) {
@@ -23808,7 +23805,7 @@ var PublishEndpoint = class {
23808
23805
  }
23809
23806
  };
23810
23807
  //#endregion
23811
- //#region ../sdk/dist/endpoints/entry-references.js
23808
+ //#region ../sdk/src/endpoints/entry-references.ts
23812
23809
  var EntryReferencesEndpoint = class {
23813
23810
  client;
23814
23811
  constructor(client) {
@@ -23839,7 +23836,7 @@ var EntryReferencesEndpoint = class {
23839
23836
  }
23840
23837
  };
23841
23838
  //#endregion
23842
- //#region ../sdk/dist/endpoints/analytics.js
23839
+ //#region ../sdk/src/endpoints/analytics.ts
23843
23840
  const RESERVED_ANALYTICS_PARAMS = /* @__PURE__ */ new Set([
23844
23841
  "pipe",
23845
23842
  "site",
@@ -23924,7 +23921,7 @@ var AnalyticsEndpoint = class {
23924
23921
  }
23925
23922
  };
23926
23923
  //#endregion
23927
- //#region ../sdk/dist/endpoints/templates.js
23924
+ //#region ../sdk/src/endpoints/templates.ts
23928
23925
  var TemplatesEndpoint = class {
23929
23926
  client;
23930
23927
  constructor(client) {
@@ -23966,7 +23963,7 @@ var TemplatesEndpoint = class {
23966
23963
  if (siteIds.length === 0) return [];
23967
23964
  const namePrefix = params?.namePrefix?.trim();
23968
23965
  const limit = Math.min(Math.max(params?.limit ?? 100, 1), 500);
23969
- let query = this.client.db.from("templates").select("id, name, reference_url, preview_img_urls, description, created_at").in("id", siteIds);
23966
+ let query = this.client.db.from("templates").select("id, name, reference_url, preview_img_urls, description, language, created_at").in("id", siteIds);
23970
23967
  if (namePrefix) query = query.ilike("name", `${namePrefix}%`);
23971
23968
  if (params?.order) query = query.order("created_at", { ascending: params.order === "asc" });
23972
23969
  query = query.limit(limit);
@@ -23982,13 +23979,14 @@ var TemplatesEndpoint = class {
23982
23979
  referenceUrl: t.reference_url,
23983
23980
  previewImgUrls: t.preview_img_urls ?? null,
23984
23981
  description: t.description ?? null,
23982
+ language: t.language ?? null,
23985
23983
  createdAt: t.created_at
23986
23984
  };
23987
23985
  });
23988
23986
  }
23989
23987
  };
23990
23988
  //#endregion
23991
- //#region ../sdk/dist/endpoints/headshots.js
23989
+ //#region ../sdk/src/endpoints/headshots.ts
23992
23990
  /**
23993
23991
  * headshot_pool is a GLOBAL (not site-scoped) admin table. RLS exposes it only to
23994
23992
  * the super-admin user, so these direct-DB calls return empty/forbidden for any
@@ -24072,7 +24070,7 @@ var HeadshotsEndpoint = class {
24072
24070
  }
24073
24071
  };
24074
24072
  //#endregion
24075
- //#region ../sdk/dist/endpoints/logos.js
24073
+ //#region ../sdk/src/endpoints/logos.ts
24076
24074
  /**
24077
24075
  * logo_pool is a GLOBAL (not site-scoped) admin table. RLS exposes it only to the
24078
24076
  * super-admin user, so these direct-DB calls return empty/forbidden for any other
@@ -24162,7 +24160,7 @@ var LogosEndpoint = class {
24162
24160
  }
24163
24161
  };
24164
24162
  //#endregion
24165
- //#region ../sdk/dist/client.js
24163
+ //#region ../sdk/src/client.ts
24166
24164
  /**
24167
24165
  * @neopress/sdk — API Client
24168
24166
  *
@@ -24239,7 +24237,8 @@ var NeopressClient = class {
24239
24237
  * header so RLS applies. No session persistence/refresh — token freshness is the
24240
24238
  * caller's responsibility (CLI getClientAsync / MCP per-request).
24241
24239
  *
24242
- * `@internal` (with `stripInternal`) keeps this out of the emitted declarations:
24240
+ * `@internal` (with `stripInternal` in the cli/mcp dts configs — sdk itself no
24241
+ * longer emits) keeps this out of the published declarations:
24243
24242
  * it is plumbing for the endpoints in this package, and typing it publicly would
24244
24243
  * drag `SupabaseClient` + the generated `Database` into the published `.d.ts` of
24245
24244
  * `@neopress/cli` / `@neopress/mcp` — packages that declare no dependencies, so
@@ -24383,7 +24382,7 @@ function getSiteId(siteIdOverride) {
24383
24382
  if (projectConfig.siteId) return projectConfig.siteId;
24384
24383
  return loadSession().activeSiteId;
24385
24384
  }
24386
- function setActiveSiteId(siteId) {
24385
+ async function setActiveSiteId(siteId) {
24387
24386
  saveSession({
24388
24387
  ...loadSession(),
24389
24388
  activeSiteId: siteId
@@ -24497,8 +24496,9 @@ Use these rules before generating pages, collection schemas, entries, forms, or
24497
24496
  - Reusable templates are normal Neopress sites marked by a \`templates\` metadata row.
24498
24497
  - \`templates.id\` equals \`sites.id\`.
24499
24498
  - Build layout, pages, collections, entries, forms, assets, compile, publish, and verify before marking a finished template.
24500
- - Use \`neopress_template_mark\` to create or update the metadata row with \`name\`, required \`referenceUrl\`, required \`previewImgUrls\` (at least one), and optional \`description\` and \`previewVideoUrl\`. The gallery card renders \`previewImgUrls[0]\` at rest and cross-fades to \`previewVideoUrl\` on hover, so the first preview image must be that video's first frame.
24499
+ - Use \`neopress_template_mark\` to create or update the metadata row with \`name\`, required \`referenceUrl\`, required \`language\`, required \`previewImgUrls\` (at least one), and optional \`description\` and \`previewVideoUrl\`. The gallery card renders \`previewImgUrls[0]\` at rest and cross-fades to \`previewVideoUrl\` on hover, so the first preview image must be that video's first frame.
24501
24500
  - \`referenceUrl\` is a single http(s) provenance URL. \`description\` is an optional summary of the template (max 150 characters).
24501
+ - \`language\` is the language the template's own content is written in, as a two-letter lowercase ISO 639-1 code (\`ko\`, \`en\`, \`es\`). Region locales like \`en-US\` are rejected.
24502
24502
  - Template metadata does not replace page TSX, CMS content, forms, or publish state.
24503
24503
  - Use \`neopress_template_delete\` only for explicit template-site hard deletion. It removes the metadata row and connected site.
24504
24504
 
@@ -24507,6 +24507,7 @@ Use these rules before generating pages, collection schemas, entries, forms, or
24507
24507
  - Dynamic pages require a collection, a page path like \`/blog/[slug]\`, a numeric \`collectionId\`, matching entries, entry publish, page compile, and site publish.
24508
24508
  - \`queryConfig\` is stored in page \`draftMeta.queryConfig\`.
24509
24509
  - Query labels become React props. Entry query results are flattened, so use \`post.body\` instead of \`post.data.body\`.
24510
+ - A query may include \`collectionWindow\` on an \`entries\` query for a dynamic detail route. Its label receives \`{ before, after, items }\`; each is a flattened-entry array around the current entry, and \`items\` combines both directions. Windows use \`published_at DESC, id DESC\`, allow 0–20 entries per direction, and may only declare that exact order. Use the exact configured label, never a guessed prop name. Optional \`matchFields\` compare raw anchor-snapshot fields only when the source field has a value.
24510
24511
  - Initial CMS data arrives through those injected query props. \`entryClient\`, when injected in reader/preview for a site handle, is only a client-side auxiliary for load-more/filter/get/create interactions after the initial render; do not use it for initial render or server data loading.
24511
24512
  - Use numeric \`collection_id\` filters. Runtime controls site, status, locale, deleted state, and published snapshots.
24512
24513
 
@@ -24566,6 +24567,198 @@ function toToolError(error) {
24566
24567
  }]
24567
24568
  };
24568
24569
  }
24570
+ var AssetImportError = class extends Error {
24571
+ constructor(message) {
24572
+ super(message);
24573
+ this.name = "AssetImportError";
24574
+ }
24575
+ };
24576
+ function ipv4ToInt(address) {
24577
+ const parts = address.split(".");
24578
+ if (parts.length !== 4) return null;
24579
+ let value = 0;
24580
+ for (const part of parts) {
24581
+ const octet = Number(part);
24582
+ if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null;
24583
+ value = value * 256 + octet;
24584
+ }
24585
+ return value;
24586
+ }
24587
+ /**
24588
+ * Blocks every address that is not routable on the public internet. Includes
24589
+ * the ranges that matter for cloud metadata (169.254.0.0/16) and for reaching
24590
+ * back into our own VPC.
24591
+ */
24592
+ function isBlockedAddress(address) {
24593
+ const family = isIP(address);
24594
+ if (family === 4) {
24595
+ const value = ipv4ToInt(address);
24596
+ if (value === null) return true;
24597
+ const inRange = (cidrBase, bits) => {
24598
+ const base = ipv4ToInt(cidrBase);
24599
+ if (base === null) return false;
24600
+ const mask = bits === 0 ? 0 : -1 << 32 - bits >>> 0;
24601
+ return (value & mask) === (base & mask);
24602
+ };
24603
+ return inRange("0.0.0.0", 8) || inRange("10.0.0.0", 8) || inRange("100.64.0.0", 10) || inRange("127.0.0.0", 8) || inRange("169.254.0.0", 16) || inRange("172.16.0.0", 12) || inRange("192.0.0.0", 24) || inRange("192.168.0.0", 16) || inRange("198.18.0.0", 15) || inRange("224.0.0.0", 4) || inRange("240.0.0.0", 4);
24604
+ }
24605
+ if (family === 6) {
24606
+ const normalized = address.toLowerCase().split("%")[0] ?? "";
24607
+ const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
24608
+ if (mapped?.[1]) return isBlockedAddress(mapped[1]);
24609
+ if (normalized === "::" || normalized === "::1") return true;
24610
+ return normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb");
24611
+ }
24612
+ return true;
24613
+ }
24614
+ /**
24615
+ * Rejects non-public destinations. Resolving here (rather than trusting the
24616
+ * hostname) is what stops `metadata.internal`-style names and DNS entries that
24617
+ * deliberately point at private space.
24618
+ */
24619
+ async function assertPublicUrl(rawUrl, resolver = defaultResolver) {
24620
+ let url;
24621
+ try {
24622
+ url = new URL(rawUrl);
24623
+ } catch {
24624
+ throw new AssetImportError(`Not a valid URL: ${rawUrl}`);
24625
+ }
24626
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new AssetImportError(`Only http(s) URLs can be imported, got ${url.protocol}`);
24627
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
24628
+ const addresses = isIP(hostname) ? [hostname] : await resolver(hostname);
24629
+ if (addresses.length === 0) throw new AssetImportError(`Could not resolve ${url.hostname}`);
24630
+ for (const address of addresses) if (isBlockedAddress(address)) throw new AssetImportError(`Refusing to fetch ${url.hostname}: it resolves to the non-public address ${address}`);
24631
+ return url;
24632
+ }
24633
+ async function defaultResolver(hostname) {
24634
+ return (await lookup(hostname, { all: true }).catch(() => [])).map((record) => record.address);
24635
+ }
24636
+ /**
24637
+ * Fetches the URL with every hop validated, then reads the body under a hard
24638
+ * byte cap. `redirect: 'manual'` is deliberate: letting fetch follow redirects
24639
+ * would skip the address check on the hops in between.
24640
+ */
24641
+ async function fetchImportableAsset(rawUrl, options) {
24642
+ const maxBytes = options.maxBytes ?? 52428800;
24643
+ let currentUrl = rawUrl;
24644
+ for (let hop = 0; hop <= 3; hop += 1) {
24645
+ const url = await assertPublicUrl(currentUrl, options.resolver);
24646
+ const controller = new AbortController();
24647
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 3e4);
24648
+ let response;
24649
+ try {
24650
+ response = await options.fetch(url.toString(), {
24651
+ redirect: "manual",
24652
+ signal: controller.signal
24653
+ });
24654
+ } finally {
24655
+ clearTimeout(timer);
24656
+ }
24657
+ if (response.status >= 300 && response.status < 400) {
24658
+ const location = response.headers.get("location");
24659
+ if (!location) throw new AssetImportError(`Redirect from ${url.toString()} had no Location header`);
24660
+ currentUrl = new URL(location, url).toString();
24661
+ continue;
24662
+ }
24663
+ if (!response.ok) throw new AssetImportError(`Source responded ${response.status} ${response.statusText || ""}`.trim());
24664
+ const declaredLength = Number(response.headers.get("content-length"));
24665
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) throw new AssetImportError(`Source is ${declaredLength} bytes, over the ${maxBytes} byte limit`);
24666
+ return {
24667
+ bytes: await readCapped(response, maxBytes),
24668
+ contentType: (response.headers.get("content-type") || "").split(";")[0]?.trim() || "",
24669
+ finalUrl: url.toString()
24670
+ };
24671
+ }
24672
+ throw new AssetImportError(`Too many redirects (max 3)`);
24673
+ }
24674
+ /** Reads the body incrementally so a lying Content-Length cannot blow the cap. */
24675
+ async function readCapped(response, maxBytes) {
24676
+ const reader = response.body?.getReader();
24677
+ if (!reader) {
24678
+ const buffer = new Uint8Array(await response.arrayBuffer());
24679
+ if (buffer.byteLength > maxBytes) throw new AssetImportError(`Source exceeds the ${maxBytes} byte limit`);
24680
+ return buffer;
24681
+ }
24682
+ const chunks = [];
24683
+ let total = 0;
24684
+ for (;;) {
24685
+ const { done, value } = await reader.read();
24686
+ if (done) break;
24687
+ if (!value) continue;
24688
+ total += value.byteLength;
24689
+ if (total > maxBytes) {
24690
+ await reader.cancel().catch(() => {});
24691
+ throw new AssetImportError(`Source exceeds the ${maxBytes} byte limit`);
24692
+ }
24693
+ chunks.push(value);
24694
+ }
24695
+ const merged = new Uint8Array(total);
24696
+ let offset = 0;
24697
+ for (const chunk of chunks) {
24698
+ merged.set(chunk, offset);
24699
+ offset += chunk.byteLength;
24700
+ }
24701
+ return merged;
24702
+ }
24703
+ /**
24704
+ * Derives a storage-safe file name. The source path is only a hint — anything
24705
+ * outside the allowed character set is dropped so a crafted URL cannot steer
24706
+ * the object key.
24707
+ */
24708
+ function deriveImportFileName(finalUrl, contentType) {
24709
+ const sanitized = decodeURIComponent(new URL(finalUrl).pathname.split("/").pop() || "").replace(/[^\w.-]/g, "").replace(/^\.+/, "");
24710
+ if (sanitized && /\.[a-z0-9]{1,5}$/i.test(sanitized)) return sanitized.slice(0, 120);
24711
+ const extension = extensionForContentType(contentType);
24712
+ const stem = (sanitized || "imported-asset").slice(0, 100);
24713
+ return extension ? `${stem}.${extension}` : stem;
24714
+ }
24715
+ function extensionForContentType(contentType) {
24716
+ return {
24717
+ "image/avif": "avif",
24718
+ "image/gif": "gif",
24719
+ "image/jpeg": "jpg",
24720
+ "image/png": "png",
24721
+ "image/svg+xml": "svg",
24722
+ "image/webp": "webp",
24723
+ "video/mp4": "mp4",
24724
+ "application/pdf": "pdf",
24725
+ "text/csv": "csv"
24726
+ }[contentType.toLowerCase()] ?? null;
24727
+ }
24728
+ /**
24729
+ * Ceiling for the inline (base64) upload fallback. Enforced on the *decoded*
24730
+ * bytes: capping the base64 string instead would let a caller slip ~33% more
24731
+ * through, and the number that matters downstream is the object size.
24732
+ *
24733
+ * 1MB is a deliberate cost ceiling rather than a technical one. Inline bytes
24734
+ * traverse the model's context, so 1MB costs roughly 90K tokens — already
24735
+ * enough that a caller should reach for presign or URL import when it can.
24736
+ */
24737
+ const MAX_INLINE_UPLOAD_BYTES = 1048576;
24738
+ /**
24739
+ * Decodes a base64 payload, tolerating a `data:` URL wrapper since models
24740
+ * routinely produce one. Rejects anything that is not valid base64: Buffer
24741
+ * silently drops invalid characters, so a corrupted payload would otherwise
24742
+ * upload as a truncated file instead of failing.
24743
+ */
24744
+ function decodeInlineUpload(payload, maxBytes = MAX_INLINE_UPLOAD_BYTES) {
24745
+ const dataUrl = payload.match(/^data:([^;,]*)(;base64)?,(.*)$/s);
24746
+ const contentTypeHint = dataUrl?.[1]?.trim() || null;
24747
+ const base64 = (dataUrl?.[3] ?? payload).replace(/\s/g, "");
24748
+ if (base64.length === 0) throw new AssetImportError("base64Content is empty.");
24749
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(base64)) throw new AssetImportError("base64Content is not valid base64. Send the raw base64 of the file (a data: URL prefix is also accepted).");
24750
+ const approximateBytes = Math.floor(base64.length * 3 / 4);
24751
+ if (approximateBytes > maxBytes) throw new AssetImportError(oversizeMessage(approximateBytes, maxBytes));
24752
+ const bytes = new Uint8Array(Buffer.from(base64, "base64"));
24753
+ if (bytes.byteLength > maxBytes) throw new AssetImportError(oversizeMessage(bytes.byteLength, maxBytes));
24754
+ return {
24755
+ bytes,
24756
+ contentTypeHint
24757
+ };
24758
+ }
24759
+ function oversizeMessage(actual, maxBytes) {
24760
+ return `Inline upload is ${Math.round(actual / 1024)}KB, over the ${Math.round(maxBytes / 1024)}KB limit. Inline bytes pass through the conversation and are expensive; use neopress_asset_presign (upload straight to storage) or neopress_asset_import_url (server fetches a public URL) instead.`;
24761
+ }
24569
24762
  //#endregion
24570
24763
  //#region src/tools.ts
24571
24764
  const siteIdShape = { siteId: z.number().int().positive().optional() };
@@ -24678,11 +24871,7 @@ function getToolMeta(tool) {
24678
24871
  return {
24679
24872
  securitySchemes: [{
24680
24873
  type: "oauth2",
24681
- scopes: [
24682
- "neopress:read",
24683
- "neopress:write",
24684
- "offline_access"
24685
- ]
24874
+ scopes: []
24686
24875
  }],
24687
24876
  "openai/toolInvocation/invoking": `Running ${tool.title}`,
24688
24877
  "openai/toolInvocation/invoked": `Finished ${tool.title}`
@@ -24760,7 +24949,7 @@ const toolDefinitions = [
24760
24949
  handler: async (input, context, extra) => {
24761
24950
  const selectedSiteId = asNumber(input, "siteId");
24762
24951
  const site = await (await context.createClient({ siteId: selectedSiteId }, extra)).site.get();
24763
- context.setActiveSiteId(selectedSiteId, extra);
24952
+ await context.setActiveSiteId(selectedSiteId, extra);
24764
24953
  return {
24765
24954
  siteId: selectedSiteId,
24766
24955
  baseUrl: context.getBaseUrl(),
@@ -24797,7 +24986,7 @@ const toolDefinitions = [
24797
24986
  language: asOptionalString(input, "language"),
24798
24987
  placeholder: asOptionalBoolean(input, "placeholder")
24799
24988
  });
24800
- if (input.select !== false) context.setActiveSiteId(site.id, extra);
24989
+ if (input.select !== false) await context.setActiveSiteId(site.id, extra);
24801
24990
  return site;
24802
24991
  }
24803
24992
  },
@@ -24835,12 +25024,13 @@ const toolDefinitions = [
24835
25024
  {
24836
25025
  name: "neopress_template_mark",
24837
25026
  title: "Mark Site As Template",
24838
- description: "Create or update the template metadata row for a finished template site. The template id is the site id. Requires at least one previewImgUrl. The gallery card shows previewImgUrls[0] at rest and plays previewVideoUrl on hover, so pass the video together with its own first frame as the first preview image.",
25027
+ description: "Create or update the template metadata row for a finished template site. The template id is the site id. Requires at least one previewImgUrl and a language. The gallery card shows previewImgUrls[0] at rest and plays previewVideoUrl on hover, so pass the video together with its own first frame as the first preview image.",
24839
25028
  inputSchema: {
24840
25029
  ...siteIdShape,
24841
25030
  name: z.string(),
24842
25031
  description: z.string().max(150).optional(),
24843
25032
  referenceUrl: httpUrlSchema,
25033
+ language: z.string().regex(/^[a-z]{2}$/, "must be a two-letter ISO 639-1 language code (e.g. ko, en, es)").describe("Primary language of the template's own content, as a two-letter lowercase ISO 639-1 code (ko, en, es). Not a region locale like en-US."),
24844
25034
  previewImgUrls: z.array(z.string()).min(1),
24845
25035
  previewVideoUrl: httpUrlSchema.optional()
24846
25036
  },
@@ -24849,6 +25039,7 @@ const toolDefinitions = [
24849
25039
  name: asString(input, "name"),
24850
25040
  description: asOptionalString(input, "description"),
24851
25041
  referenceUrl: asString(input, "referenceUrl"),
25042
+ language: asString(input, "language"),
24852
25043
  previewImgUrls: input.previewImgUrls,
24853
25044
  previewVideoUrl: asOptionalString(input, "previewVideoUrl")
24854
25045
  });
@@ -25549,6 +25740,118 @@ const toolDefinitions = [
25549
25740
  });
25550
25741
  }
25551
25742
  },
25743
+ {
25744
+ name: "neopress_asset_presign",
25745
+ title: "Get Presigned Asset Upload URL",
25746
+ description: "Preferred way to upload bytes you hold (a local file, a generated image): returns a short-lived URL to PUT the file straight to Neopress storage, then call neopress_asset_register with the returned publicUrl. Nothing passes through the conversation, so size and count cost you nothing. This requires your environment to reach the storage host directly — if that PUT is refused by a proxy or egress allowlist, fall back to neopress_asset_upload_base64. neopress_asset_upload_file does all three steps at once but only over a local (stdio) connection, since a remote server cannot read your filesystem.",
25747
+ inputSchema: {
25748
+ ...siteIdShape,
25749
+ fileName: z.string(),
25750
+ contentType: z.string().optional(),
25751
+ contentLength: z.number().int().positive(),
25752
+ folder: z.string().optional()
25753
+ },
25754
+ handler: async (input, context, extra) => {
25755
+ const client = await context.createClient({ siteId: siteId(input) }, extra);
25756
+ const fileName = basename(asString(input, "fileName"));
25757
+ const folder = asOptionalString(input, "folder") || "uploads";
25758
+ return client.assets.presign({
25759
+ fileKey: `${folder}/${Date.now()}-${fileName}`,
25760
+ contentType: asOptionalString(input, "contentType") || getContentType(fileName),
25761
+ contentLength: asNumber(input, "contentLength")
25762
+ });
25763
+ }
25764
+ },
25765
+ {
25766
+ name: "neopress_asset_import_url",
25767
+ title: "Import Asset From URL",
25768
+ description: "Copy a publicly reachable file into Neopress storage and register it as an asset. Unlike neopress_asset_register — which only records an external URL and breaks when the source moves — this stores our own copy. The server does the fetching, so no bytes pass through the conversation.",
25769
+ inputSchema: {
25770
+ ...siteIdShape,
25771
+ sourceUrl: z.string().url(),
25772
+ folder: z.string().optional(),
25773
+ displayName: z.string().optional(),
25774
+ altText: z.string().optional(),
25775
+ description: z.string().optional(),
25776
+ tags: z.array(z.string()).optional(),
25777
+ metadata: optionalJsonObjectSchema
25778
+ },
25779
+ handler: async (input, context, extra) => {
25780
+ const client = await context.createClient({ siteId: siteId(input) }, extra);
25781
+ const fetched = await fetchImportableAsset(asString(input, "sourceUrl"), { fetch: context.fetch });
25782
+ const fileName = deriveImportFileName(fetched.finalUrl, fetched.contentType);
25783
+ const contentType = fetched.contentType || getContentType(fileName);
25784
+ const folder = asOptionalString(input, "folder") || "uploads";
25785
+ const { presignedUrl, publicUrl } = await client.assets.presign({
25786
+ fileKey: `${folder}/${Date.now()}-${fileName}`,
25787
+ contentType,
25788
+ contentLength: fetched.bytes.byteLength
25789
+ });
25790
+ const uploadResponse = await context.fetch(presignedUrl, {
25791
+ method: "PUT",
25792
+ headers: { "Content-Type": contentType },
25793
+ body: fetched.bytes
25794
+ });
25795
+ if (!uploadResponse.ok) throw new Error(`Asset upload failed: ${uploadResponse.status} ${uploadResponse.statusText}`);
25796
+ return client.assets.register({
25797
+ assetUrl: publicUrl,
25798
+ displayName: asOptionalString(input, "displayName") || fileName,
25799
+ mimeType: contentType,
25800
+ size: fetched.bytes.byteLength,
25801
+ altText: asOptionalString(input, "altText"),
25802
+ description: asOptionalString(input, "description"),
25803
+ tags: input.tags,
25804
+ folderPath: folder,
25805
+ metadata: input.metadata
25806
+ });
25807
+ }
25808
+ },
25809
+ {
25810
+ name: "neopress_asset_upload_base64",
25811
+ title: "Upload Asset Inline (base64)",
25812
+ description: "Fallback upload for environments with no outbound network of their own: the bytes ride inside this tool call, so they travel over the connection your host already has instead of one you must open. Expensive — inline bytes pass through the conversation, so ~1MB costs roughly 90K tokens, and the limit is 1MB decoded. Reach for this only when neopress_asset_presign is refused by a proxy or egress allowlist, and prefer neopress_asset_import_url whenever the file already has a public URL.",
25813
+ inputSchema: {
25814
+ ...siteIdShape,
25815
+ fileName: z.string(),
25816
+ base64Content: z.string(),
25817
+ contentType: z.string().optional(),
25818
+ folder: z.string().optional(),
25819
+ displayName: z.string().optional(),
25820
+ altText: z.string().optional(),
25821
+ description: z.string().optional(),
25822
+ tags: z.array(z.string()).optional(),
25823
+ metadata: optionalJsonObjectSchema
25824
+ },
25825
+ handler: async (input, context, extra) => {
25826
+ const client = await context.createClient({ siteId: siteId(input) }, extra);
25827
+ const fileName = basename(asString(input, "fileName"));
25828
+ const { bytes, contentTypeHint } = decodeInlineUpload(asString(input, "base64Content"));
25829
+ const contentType = asOptionalString(input, "contentType") || contentTypeHint || getContentType(fileName);
25830
+ const folder = asOptionalString(input, "folder") || "uploads";
25831
+ const { presignedUrl, publicUrl } = await client.assets.presign({
25832
+ fileKey: `${folder}/${Date.now()}-${fileName}`,
25833
+ contentType,
25834
+ contentLength: bytes.byteLength
25835
+ });
25836
+ const uploadResponse = await context.fetch(presignedUrl, {
25837
+ method: "PUT",
25838
+ headers: { "Content-Type": contentType },
25839
+ body: bytes
25840
+ });
25841
+ if (!uploadResponse.ok) throw new Error(`Asset upload failed: ${uploadResponse.status} ${uploadResponse.statusText}`);
25842
+ return client.assets.register({
25843
+ assetUrl: publicUrl,
25844
+ displayName: asOptionalString(input, "displayName") || fileName,
25845
+ mimeType: contentType,
25846
+ size: bytes.byteLength,
25847
+ altText: asOptionalString(input, "altText"),
25848
+ description: asOptionalString(input, "description"),
25849
+ tags: input.tags,
25850
+ folderPath: folder,
25851
+ metadata: input.metadata
25852
+ });
25853
+ }
25854
+ },
25552
25855
  {
25553
25856
  name: "neopress_asset_upload_file",
25554
25857
  title: "Upload Local Asset File",
@@ -25668,19 +25971,65 @@ function createNeopressMcpServer(options = {}) {
25668
25971
  return server;
25669
25972
  }
25670
25973
  //#endregion
25974
+ //#region src/remote/redis.ts
25975
+ let redisClient;
25976
+ /**
25977
+ * Upstash Redis client factory. Returns `undefined` when the environment is
25978
+ * not configured so callers can fall back to in-memory implementations.
25979
+ */
25980
+ function getRedisClient() {
25981
+ if (redisClient) return redisClient;
25982
+ const url = process.env.UPSTASH_REDIS_REST_URL;
25983
+ const token = process.env.UPSTASH_REDIS_REST_TOKEN;
25984
+ if (!url || !token) return void 0;
25985
+ redisClient = new Redis({
25986
+ url,
25987
+ token
25988
+ });
25989
+ return redisClient;
25990
+ }
25991
+ //#endregion
25671
25992
  //#region src/remote/context.ts
25993
+ /** Site selections are session-like data — expire them after 30 days. */
25994
+ const SITE_SELECTION_TTL_SECONDS = 2592e3;
25995
+ function siteSelectionKey(userKey) {
25996
+ return `mcp:site-selection:${userKey}`;
25997
+ }
25672
25998
  var MemoryRemoteSiteSelectionStore = class {
25673
25999
  siteIds = /* @__PURE__ */ new Map();
25674
- get(userKey) {
26000
+ async get(userKey) {
25675
26001
  return this.siteIds.get(userKey);
25676
26002
  }
25677
- set(userKey, siteId) {
26003
+ async set(userKey, siteId) {
25678
26004
  this.siteIds.set(userKey, siteId);
25679
26005
  }
25680
26006
  };
26007
+ var RedisRemoteSiteSelectionStore = class {
26008
+ redis;
26009
+ constructor(redis) {
26010
+ this.redis = redis;
26011
+ }
26012
+ async get(userKey) {
26013
+ return await this.redis.get(siteSelectionKey(userKey)) ?? void 0;
26014
+ }
26015
+ async set(userKey, siteId) {
26016
+ await this.redis.set(siteSelectionKey(userKey), siteId, { ex: SITE_SELECTION_TTL_SECONDS });
26017
+ }
26018
+ };
25681
26019
  function createMemoryRemoteSiteSelectionStore() {
25682
26020
  return new MemoryRemoteSiteSelectionStore();
25683
26021
  }
26022
+ function createRedisRemoteSiteSelectionStore(redis) {
26023
+ return new RedisRemoteSiteSelectionStore(redis);
26024
+ }
26025
+ /**
26026
+ * Redis-backed store when Upstash env is configured, in-memory otherwise.
26027
+ * Callers passing an explicit store always take precedence over this default.
26028
+ */
26029
+ function createDefaultRemoteSiteSelectionStore() {
26030
+ const redis = getRedisClient();
26031
+ return redis ? createRedisRemoteSiteSelectionStore(redis) : createMemoryRemoteSiteSelectionStore();
26032
+ }
25684
26033
  function getUserKey(extra) {
25685
26034
  const userId = extra?.authInfo?.extra?.userId;
25686
26035
  if (typeof userId === "string" && userId) return `user:${userId}`;
@@ -25696,7 +26045,7 @@ function createRemoteToolContext(options) {
25696
26045
  const siteSelectionStore = options.siteSelectionStore ?? createMemoryRemoteSiteSelectionStore();
25697
26046
  return {
25698
26047
  async createClient(clientOptions = {}, extra) {
25699
- const siteId = clientOptions.siteId ?? siteSelectionStore.get(getUserKey(extra));
26048
+ const siteId = clientOptions.siteId ?? await siteSelectionStore.get(getUserKey(extra));
25700
26049
  const requireSite = clientOptions.requireSite ?? true;
25701
26050
  if (!siteId && requireSite) throw new Error("No site selected. Call neopress_site_select or pass siteId explicitly.");
25702
26051
  const sb = getSupabaseConfig();
@@ -25715,16 +26064,19 @@ function createRemoteToolContext(options) {
25715
26064
  throw new Error("Local file uploads are only available through the stdio MCP server. Use neopress_asset_register with a public URL for remote MCP.");
25716
26065
  },
25717
26066
  fetch,
25718
- setActiveSiteId(siteId, extra) {
25719
- siteSelectionStore.set(getUserKey(extra), siteId);
26067
+ async setActiveSiteId(siteId, extra) {
26068
+ await siteSelectionStore.set(getUserKey(extra), siteId);
25720
26069
  },
25721
26070
  getBaseUrl() {
25722
26071
  return options.baseUrl;
25723
26072
  }
25724
26073
  };
25725
26074
  }
25726
- const DEFAULT_CLIENT_TTL_SECONDS = 2592e3;
25727
- let generatedClientSecret;
26075
+ /**
26076
+ * Error shape for the bearer-token verification path. The MCP server is a
26077
+ * pure Resource Server (RFC 9728) — the OAuth 2.1 Authorization Server lives
26078
+ * in Supabase, not here, so this module only verifies access tokens.
26079
+ */
25728
26080
  var OAuthProtocolError = class extends Error {
25729
26081
  status;
25730
26082
  errorCode;
@@ -25735,130 +26087,11 @@ var OAuthProtocolError = class extends Error {
25735
26087
  this.errorCode = errorCode;
25736
26088
  }
25737
26089
  };
25738
- function toBase64Url(value) {
25739
- return Buffer.from(value, "utf-8").toString("base64url");
25740
- }
25741
- function fromBase64Url(value) {
25742
- return Buffer.from(value, "base64url").toString("utf-8");
25743
- }
25744
- function sign(value, secret) {
25745
- return createHmac("sha256", secret).update(value).digest("base64url");
25746
- }
25747
- function safeEqual(left, right) {
25748
- const leftBuffer = Buffer.from(left);
25749
- const rightBuffer = Buffer.from(right);
25750
- return leftBuffer.byteLength === rightBuffer.byteLength && timingSafeEqual(leftBuffer, rightBuffer);
25751
- }
25752
- function getOAuthClientSecret(explicitSecret) {
25753
- if (explicitSecret) return explicitSecret;
25754
- if (process.env.NODE_ENV === "production") throw new Error("NEOPRESS_MCP_OAUTH_CLIENT_SECRET is required in production.");
25755
- generatedClientSecret ??= randomBytes(32).toString("base64url");
25756
- return generatedClientSecret;
25757
- }
25758
- function normalizeRedirectUris(redirectUris) {
25759
- if (!Array.isArray(redirectUris)) throw new OAuthProtocolError(400, "invalid_client_metadata", "redirect_uris must be an array.");
25760
- const normalized = redirectUris.map((uri) => typeof uri === "string" ? uri.trim() : "").filter(Boolean);
25761
- if (normalized.length === 0) throw new OAuthProtocolError(400, "invalid_client_metadata", "At least one redirect_uri is required.");
25762
- for (const uri of normalized) try {
25763
- new URL(uri);
25764
- } catch {
25765
- throw new OAuthProtocolError(400, "invalid_redirect_uri", `Invalid redirect_uri: ${uri}`);
25766
- }
25767
- return [...new Set(normalized)];
25768
- }
25769
- function createClientRegistration(input, secret, nowSeconds = Math.floor(Date.now() / 1e3)) {
25770
- const redirectUris = normalizeRedirectUris(input.redirect_uris);
25771
- const payload = {
25772
- v: 1,
25773
- clientName: input.client_name,
25774
- redirectUris,
25775
- issuedAt: nowSeconds,
25776
- expiresAt: nowSeconds + DEFAULT_CLIENT_TTL_SECONDS
25777
- };
25778
- const encodedPayload = toBase64Url(JSON.stringify(payload));
25779
- return {
25780
- client_id: `${encodedPayload}.${sign(encodedPayload, secret)}`,
25781
- client_id_issued_at: payload.issuedAt,
25782
- client_name: payload.clientName,
25783
- redirect_uris: payload.redirectUris,
25784
- token_endpoint_auth_method: "none"
25785
- };
25786
- }
25787
- function verifyClientRegistration(clientId, secret, nowSeconds = Math.floor(Date.now() / 1e3)) {
25788
- const [encodedPayload, signature] = clientId.split(".");
25789
- if (!encodedPayload || !signature) throw new OAuthProtocolError(401, "invalid_client", "Invalid client_id.");
25790
- if (!safeEqual(signature, sign(encodedPayload, secret))) throw new OAuthProtocolError(401, "invalid_client", "Invalid client_id.");
25791
- let payload;
25792
- try {
25793
- payload = JSON.parse(fromBase64Url(encodedPayload));
25794
- } catch {
25795
- throw new OAuthProtocolError(401, "invalid_client", "Invalid client_id.");
25796
- }
25797
- if (payload.v !== 1 || !Array.isArray(payload.redirectUris)) throw new OAuthProtocolError(401, "invalid_client", "Invalid client_id.");
25798
- if (payload.expiresAt <= nowSeconds) throw new OAuthProtocolError(401, "invalid_client", "client_id has expired.");
25799
- return payload;
25800
- }
25801
- function requireParam(params, name) {
25802
- const value = params.get(name);
25803
- if (!value) throw new OAuthProtocolError(400, "invalid_request", `Missing required parameter: ${name}`);
25804
- return value;
25805
- }
25806
- function createAuthorizationRedirectUrl(requestUrl, config) {
25807
- if (requireParam(requestUrl.searchParams, "response_type") !== "code") throw new OAuthProtocolError(400, "unsupported_response_type", "Only response_type=code is supported.");
25808
- const client = verifyClientRegistration(requireParam(requestUrl.searchParams, "client_id"), config.clientSecret);
25809
- const redirectUri = requireParam(requestUrl.searchParams, "redirect_uri");
25810
- if (!client.redirectUris.includes(redirectUri)) throw new OAuthProtocolError(400, "invalid_redirect_uri", "redirect_uri is not registered for this client.");
25811
- const codeChallenge = requireParam(requestUrl.searchParams, "code_challenge");
25812
- if ((requestUrl.searchParams.get("code_challenge_method") || "S256") !== "S256") throw new OAuthProtocolError(400, "invalid_request", "Only S256 PKCE is supported.");
25813
- const redirectTo = new URL(redirectUri);
25814
- const state = requestUrl.searchParams.get("state");
25815
- if (state) redirectTo.searchParams.set("state", state);
25816
- const supabaseAuthorizeUrl = new URL(`${config.supabaseUrl}/auth/v1/authorize`);
25817
- supabaseAuthorizeUrl.searchParams.set("provider", config.provider);
25818
- supabaseAuthorizeUrl.searchParams.set("redirect_to", redirectTo.toString());
25819
- supabaseAuthorizeUrl.searchParams.set("code_challenge", codeChallenge);
25820
- supabaseAuthorizeUrl.searchParams.set("code_challenge_method", "S256");
25821
- return supabaseAuthorizeUrl.toString();
25822
- }
25823
- async function proxyTokenRequest(params, config) {
25824
- verifyClientRegistration(requireParam(params, "client_id"), config.clientSecret);
25825
- const grantType = requireParam(params, "grant_type");
25826
- let supabaseGrantType;
25827
- let body;
25828
- if (grantType === "authorization_code") {
25829
- supabaseGrantType = "pkce";
25830
- body = {
25831
- auth_code: requireParam(params, "code"),
25832
- code_verifier: requireParam(params, "code_verifier")
25833
- };
25834
- } else if (grantType === "refresh_token") {
25835
- supabaseGrantType = "refresh_token";
25836
- body = { refresh_token: requireParam(params, "refresh_token") };
25837
- } else throw new OAuthProtocolError(400, "unsupported_grant_type", "Only authorization_code and refresh_token grants are supported.");
25838
- const response = await (config.fetch ?? fetch)(`${config.supabaseUrl}/auth/v1/token?grant_type=${supabaseGrantType}`, {
25839
- method: "POST",
25840
- headers: {
25841
- "Content-Type": "application/json",
25842
- apikey: config.supabaseAnonKey
25843
- },
25844
- body: JSON.stringify(body)
25845
- });
25846
- const responseText = await response.text();
25847
- let data;
25848
- try {
25849
- data = responseText ? JSON.parse(responseText) : {};
25850
- } catch {
25851
- data = { message: responseText };
25852
- }
25853
- if (!response.ok) throw new OAuthProtocolError(response.status, "invalid_grant", typeof data.message === "string" ? data.message : "Token exchange failed.");
25854
- return {
25855
- access_token: data.access_token,
25856
- refresh_token: data.refresh_token,
25857
- expires_in: data.expires_in,
25858
- token_type: typeof data.token_type === "string" ? data.token_type : "bearer",
25859
- scope: "neopress:read neopress:write offline_access"
25860
- };
25861
- }
26090
+ /**
26091
+ * Live verification against Supabase GoTrue (`GET /auth/v1/user`). Live
26092
+ * lookups mean a global logout invalidates tokens immediately — a JWKS/local
26093
+ * verification swap is a pre-listing gate, not a launch requirement.
26094
+ */
25862
26095
  async function verifySupabaseAccessToken(accessToken, config) {
25863
26096
  const response = await (config.fetch ?? fetch)(`${config.supabaseUrl}/auth/v1/user`, {
25864
26097
  method: "GET",
@@ -25887,11 +26120,25 @@ async function verifySupabaseAccessToken(accessToken, config) {
25887
26120
  //#region src/remote/http-server.ts
25888
26121
  const MCP_PATH = "/mcp";
25889
26122
  const DISABLED_REMOTE_TOOLS = ["neopress_asset_upload_file"];
25890
- const OAUTH_SCOPES = [
25891
- "neopress:read",
25892
- "neopress:write",
25893
- "offline_access"
25894
- ];
26123
+ /**
26124
+ * Production guard: site selections must survive restarts and be shared
26125
+ * across instances, so production requires Upstash Redis unless an explicit
26126
+ * store is injected.
26127
+ */
26128
+ function resolveSiteSelectionStore(explicitStore) {
26129
+ if (explicitStore) return explicitStore;
26130
+ if (!getRedisClient() && process.env.NODE_ENV === "production") throw new Error("UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are required in production so site selections persist across restarts and instances.");
26131
+ return createDefaultRemoteSiteSelectionStore();
26132
+ }
26133
+ /**
26134
+ * Production guard: the protected-resource metadata URLs (`resource` and the
26135
+ * 401 `resource_metadata` pointer) are built from the public URL, so
26136
+ * production must not fall back to request-header-derived origins.
26137
+ */
26138
+ function resolvePublicUrl(explicitPublicUrl) {
26139
+ if (explicitPublicUrl) return explicitPublicUrl.replace(/\/$/, "");
26140
+ if (process.env.NODE_ENV === "production") throw new Error("NEOPRESS_MCP_PUBLIC_URL is required in production.");
26141
+ }
25895
26142
  function resolveOptions(options) {
25896
26143
  return {
25897
26144
  authMode: options.authMode ?? "oauth",
@@ -25904,11 +26151,10 @@ function resolveOptions(options) {
25904
26151
  ],
25905
26152
  fetch: options.fetch ?? fetch,
25906
26153
  host: options.host ?? "0.0.0.0",
25907
- oauthClientSecret: getOAuthClientSecret(options.oauthClientSecret),
25908
- oauthProvider: options.oauthProvider ?? "google",
26154
+ openaiAppsChallengeToken: options.openaiAppsChallengeToken,
25909
26155
  port: options.port ?? 4002,
25910
- publicUrl: options.publicUrl?.replace(/\/$/, ""),
25911
- siteSelectionStore: options.siteSelectionStore ?? createMemoryRemoteSiteSelectionStore(),
26156
+ publicUrl: resolvePublicUrl(options.publicUrl),
26157
+ siteSelectionStore: resolveSiteSelectionStore(options.siteSelectionStore),
25912
26158
  supabaseAnonKey: options.supabaseAnonKey ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imd4cmZ5am1zbWlha2lybXR6a212Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjIxNDM2MzQsImV4cCI6MjA3NzcxOTYzNH0.Xzx6Xg5O1EBXZ64_5BP7KhsVyafJ0GI-62HcDpq6WRk",
25913
26159
  supabaseUrl: (options.supabaseUrl ?? "https://gxrfyjmsmiakirmtzkmv.supabase.co").replace(/\/$/, "")
25914
26160
  };
@@ -25955,22 +26201,9 @@ function sendText(response, body, options = {}) {
25955
26201
  for (const [key, value] of Object.entries(options.headers ?? {})) response.setHeader(key, value);
25956
26202
  response.end(body);
25957
26203
  }
25958
- function sendOAuthError(response, error) {
25959
- if (error instanceof OAuthProtocolError) {
25960
- sendJson(response, {
25961
- error: error.errorCode,
25962
- error_description: error.message
25963
- }, { status: error.status });
25964
- return;
25965
- }
25966
- sendJson(response, {
25967
- error: "server_error",
25968
- error_description: error instanceof Error ? error.message : "Unexpected OAuth error."
25969
- }, { status: 500 });
25970
- }
25971
26204
  function sendUnauthorized(request, response, options) {
25972
- const issuer = getRequestBaseUrl(request, options);
25973
- response.setHeader("WWW-Authenticate", `Bearer resource_metadata="${issuer}/.well-known/oauth-protected-resource/mcp"`);
26205
+ const resourceBaseUrl = getRequestBaseUrl(request, options);
26206
+ response.setHeader("WWW-Authenticate", `Bearer resource_metadata="${resourceBaseUrl}/.well-known/oauth-protected-resource/mcp"`);
25974
26207
  sendJson(response, {
25975
26208
  error: "unauthorized",
25976
26209
  error_description: "A valid Neopress OAuth bearer token is required."
@@ -25979,128 +26212,30 @@ function sendUnauthorized(request, response, options) {
25979
26212
  function readBearerToken(request) {
25980
26213
  return (getHeaderValue(request.headers.authorization)?.match(/^Bearer\s+(.+)$/i))?.[1]?.trim() || null;
25981
26214
  }
25982
- async function readBody(request) {
25983
- const chunks = [];
25984
- let size = 0;
25985
- for await (const chunk of request) {
25986
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
25987
- size += buffer.byteLength;
25988
- if (size > 1048576) throw new OAuthProtocolError(413, "invalid_request", "Request body is too large.");
25989
- chunks.push(buffer);
25990
- }
25991
- return Buffer.concat(chunks).toString("utf-8");
25992
- }
25993
- async function readRequestParams(request) {
25994
- const body = await readBody(request);
25995
- if ((getHeaderValue(request.headers["content-type"]) ?? "").includes("application/json")) {
25996
- let parsed;
25997
- try {
25998
- parsed = body ? JSON.parse(body) : {};
25999
- } catch {
26000
- throw new OAuthProtocolError(400, "invalid_request", "Invalid JSON body.");
26001
- }
26002
- const params = new URLSearchParams();
26003
- for (const [key, value] of Object.entries(parsed)) if (Array.isArray(value)) {
26004
- for (const item of value) if (item !== void 0 && item !== null) params.append(key, String(item));
26005
- } else if (value !== void 0 && value !== null) params.set(key, String(value));
26006
- return params;
26007
- }
26008
- return new URLSearchParams(body);
26009
- }
26010
- function protectedResourceMetadata(issuer) {
26215
+ /**
26216
+ * RFC 9728 Protected Resource Metadata. This server is RS-only: clients
26217
+ * discover the Supabase OAuth 2.1 Authorization Server from
26218
+ * `authorization_servers` and run DCR, authorize, and token exchange against
26219
+ * it directly.
26220
+ *
26221
+ * `scopes_supported` is intentionally absent — clients copy advertised scopes
26222
+ * verbatim into their authorize request, and Supabase rejects custom scopes
26223
+ * (and returns 500 for `openid`), so advertising any value here would break
26224
+ * every connection.
26225
+ */
26226
+ function protectedResourceMetadata(resourceBaseUrl, options) {
26011
26227
  return {
26012
- resource: `${issuer}${MCP_PATH}`,
26013
- authorization_servers: [issuer],
26228
+ resource: `${resourceBaseUrl}${MCP_PATH}`,
26229
+ authorization_servers: [`${options.supabaseUrl}/auth/v1`],
26014
26230
  bearer_methods_supported: ["header"],
26015
- scopes_supported: OAUTH_SCOPES,
26016
26231
  resource_documentation: "https://app.neopress.ai"
26017
26232
  };
26018
26233
  }
26019
- function authorizationServerMetadata(issuer) {
26020
- return {
26021
- issuer,
26022
- authorization_endpoint: `${issuer}/oauth/authorize`,
26023
- token_endpoint: `${issuer}/oauth/token`,
26024
- registration_endpoint: `${issuer}/oauth/register`,
26025
- response_types_supported: ["code"],
26026
- grant_types_supported: ["authorization_code", "refresh_token"],
26027
- token_endpoint_auth_methods_supported: ["none"],
26028
- code_challenge_methods_supported: ["S256"],
26029
- scopes_supported: OAUTH_SCOPES,
26030
- resource_parameter_supported: true
26031
- };
26032
- }
26033
- function parseRedirectUrisParam(value) {
26034
- if (!value) return [];
26035
- if (!value.startsWith("[")) return [value];
26036
- try {
26037
- const parsed = JSON.parse(value);
26038
- return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [];
26039
- } catch {
26040
- throw new OAuthProtocolError(400, "invalid_client_metadata", "redirect_uris must be a string or JSON array of strings.");
26041
- }
26042
- }
26043
- async function handleRegister(request, response, options) {
26044
- if (request.method !== "POST") {
26045
- sendJson(response, { error: "method_not_allowed" }, { status: 405 });
26046
- return;
26047
- }
26048
- try {
26049
- const params = await readRequestParams(request);
26050
- const redirectUris = params.getAll("redirect_uris");
26051
- const parsed = params.get("redirect_uris");
26052
- sendJson(response, createClientRegistration({
26053
- client_name: params.get("client_name") ?? void 0,
26054
- redirect_uris: redirectUris.length > 1 ? redirectUris : parseRedirectUrisParam(parsed)
26055
- }, options.oauthClientSecret), { status: 201 });
26056
- } catch (error) {
26057
- sendOAuthError(response, error);
26058
- }
26059
- }
26060
- function handleAuthorize(request, response, options) {
26061
- if (request.method !== "GET") {
26062
- sendJson(response, { error: "method_not_allowed" }, { status: 405 });
26063
- return;
26064
- }
26065
- try {
26066
- const authorizationUrl = createAuthorizationRedirectUrl(getRequestUrl(request, options), {
26067
- clientSecret: options.oauthClientSecret,
26068
- fetch: options.fetch,
26069
- issuer: getRequestBaseUrl(request, options),
26070
- provider: options.oauthProvider,
26071
- supabaseAnonKey: options.supabaseAnonKey,
26072
- supabaseUrl: options.supabaseUrl
26073
- });
26074
- response.statusCode = 302;
26075
- response.setHeader("Location", authorizationUrl);
26076
- response.end();
26077
- } catch (error) {
26078
- sendOAuthError(response, error);
26079
- }
26080
- }
26081
- async function handleToken(request, response, options) {
26082
- if (request.method !== "POST") {
26083
- sendJson(response, { error: "method_not_allowed" }, { status: 405 });
26084
- return;
26085
- }
26086
- try {
26087
- sendJson(response, await proxyTokenRequest(await readRequestParams(request), {
26088
- clientSecret: options.oauthClientSecret,
26089
- fetch: options.fetch,
26090
- issuer: getRequestBaseUrl(request, options),
26091
- provider: options.oauthProvider,
26092
- supabaseAnonKey: options.supabaseAnonKey,
26093
- supabaseUrl: options.supabaseUrl
26094
- }));
26095
- } catch (error) {
26096
- sendOAuthError(response, error);
26097
- }
26098
- }
26099
26234
  async function authenticateMcpRequest(request, options) {
26100
26235
  if (options.authMode === "none") return {
26101
26236
  token: "",
26102
26237
  clientId: "anonymous",
26103
- scopes: OAUTH_SCOPES,
26238
+ scopes: [],
26104
26239
  extra: { userId: "anonymous" }
26105
26240
  };
26106
26241
  const accessToken = readBearerToken(request);
@@ -26113,7 +26248,7 @@ async function authenticateMcpRequest(request, options) {
26113
26248
  return {
26114
26249
  token: accessToken,
26115
26250
  clientId: "neopress-oauth",
26116
- scopes: OAUTH_SCOPES,
26251
+ scopes: [],
26117
26252
  expiresAt: user.expiresAt,
26118
26253
  extra: {
26119
26254
  email: user.email,
@@ -26131,7 +26266,7 @@ function createNeopressMcpHttpServer(rawOptions = {}) {
26131
26266
  return;
26132
26267
  }
26133
26268
  const url = getRequestUrl(request, options);
26134
- const issuer = getRequestBaseUrl(request, options);
26269
+ const resourceBaseUrl = getRequestBaseUrl(request, options);
26135
26270
  try {
26136
26271
  if (url.pathname === "/" || url.pathname === "/healthz" || url.pathname === "/livez") {
26137
26272
  sendText(response, "ok");
@@ -26142,23 +26277,15 @@ function createNeopressMcpHttpServer(rawOptions = {}) {
26142
26277
  return;
26143
26278
  }
26144
26279
  if (url.pathname === "/.well-known/oauth-protected-resource" || url.pathname === "/.well-known/oauth-protected-resource/mcp") {
26145
- sendJson(response, protectedResourceMetadata(issuer));
26146
- return;
26147
- }
26148
- if (url.pathname === "/.well-known/oauth-authorization-server" || url.pathname === "/.well-known/openid-configuration") {
26149
- sendJson(response, authorizationServerMetadata(issuer));
26150
- return;
26151
- }
26152
- if (url.pathname === "/oauth/register") {
26153
- await handleRegister(request, response, options);
26280
+ sendJson(response, protectedResourceMetadata(resourceBaseUrl, options));
26154
26281
  return;
26155
26282
  }
26156
- if (url.pathname === "/oauth/authorize") {
26157
- handleAuthorize(request, response, options);
26158
- return;
26159
- }
26160
- if (url.pathname === "/oauth/token") {
26161
- await handleToken(request, response, options);
26283
+ if (url.pathname === "/.well-known/openai-apps-challenge") {
26284
+ if (!options.openaiAppsChallengeToken) {
26285
+ sendJson(response, { error: "not_found" }, { status: 404 });
26286
+ return;
26287
+ }
26288
+ sendText(response, options.openaiAppsChallengeToken);
26162
26289
  return;
26163
26290
  }
26164
26291
  if (url.pathname === MCP_PATH) {