@neopress/mcp 1.3.1 → 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;
@@ -22987,6 +22989,7 @@ function serializeTemplate(t) {
22987
22989
  referenceUrl: t.reference_url,
22988
22990
  previewImgUrls: t.preview_img_urls,
22989
22991
  previewVideoUrl: t.preview_video_url ?? null,
22992
+ language: t.language ?? null,
22990
22993
  createdAt: t.created_at
22991
22994
  };
22992
22995
  }
@@ -23960,7 +23963,7 @@ var TemplatesEndpoint = class {
23960
23963
  if (siteIds.length === 0) return [];
23961
23964
  const namePrefix = params?.namePrefix?.trim();
23962
23965
  const limit = Math.min(Math.max(params?.limit ?? 100, 1), 500);
23963
- 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);
23964
23967
  if (namePrefix) query = query.ilike("name", `${namePrefix}%`);
23965
23968
  if (params?.order) query = query.order("created_at", { ascending: params.order === "asc" });
23966
23969
  query = query.limit(limit);
@@ -23976,6 +23979,7 @@ var TemplatesEndpoint = class {
23976
23979
  referenceUrl: t.reference_url,
23977
23980
  previewImgUrls: t.preview_img_urls ?? null,
23978
23981
  description: t.description ?? null,
23982
+ language: t.language ?? null,
23979
23983
  createdAt: t.created_at
23980
23984
  };
23981
23985
  });
@@ -24378,7 +24382,7 @@ function getSiteId(siteIdOverride) {
24378
24382
  if (projectConfig.siteId) return projectConfig.siteId;
24379
24383
  return loadSession().activeSiteId;
24380
24384
  }
24381
- function setActiveSiteId(siteId) {
24385
+ async function setActiveSiteId(siteId) {
24382
24386
  saveSession({
24383
24387
  ...loadSession(),
24384
24388
  activeSiteId: siteId
@@ -24492,8 +24496,9 @@ Use these rules before generating pages, collection schemas, entries, forms, or
24492
24496
  - Reusable templates are normal Neopress sites marked by a \`templates\` metadata row.
24493
24497
  - \`templates.id\` equals \`sites.id\`.
24494
24498
  - Build layout, pages, collections, entries, forms, assets, compile, publish, and verify before marking a finished template.
24495
- - 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.
24496
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.
24497
24502
  - Template metadata does not replace page TSX, CMS content, forms, or publish state.
24498
24503
  - Use \`neopress_template_delete\` only for explicit template-site hard deletion. It removes the metadata row and connected site.
24499
24504
 
@@ -24502,6 +24507,7 @@ Use these rules before generating pages, collection schemas, entries, forms, or
24502
24507
  - Dynamic pages require a collection, a page path like \`/blog/[slug]\`, a numeric \`collectionId\`, matching entries, entry publish, page compile, and site publish.
24503
24508
  - \`queryConfig\` is stored in page \`draftMeta.queryConfig\`.
24504
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.
24505
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.
24506
24512
  - Use numeric \`collection_id\` filters. Runtime controls site, status, locale, deleted state, and published snapshots.
24507
24513
 
@@ -24561,6 +24567,198 @@ function toToolError(error) {
24561
24567
  }]
24562
24568
  };
24563
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
+ }
24564
24762
  //#endregion
24565
24763
  //#region src/tools.ts
24566
24764
  const siteIdShape = { siteId: z.number().int().positive().optional() };
@@ -24673,11 +24871,7 @@ function getToolMeta(tool) {
24673
24871
  return {
24674
24872
  securitySchemes: [{
24675
24873
  type: "oauth2",
24676
- scopes: [
24677
- "neopress:read",
24678
- "neopress:write",
24679
- "offline_access"
24680
- ]
24874
+ scopes: []
24681
24875
  }],
24682
24876
  "openai/toolInvocation/invoking": `Running ${tool.title}`,
24683
24877
  "openai/toolInvocation/invoked": `Finished ${tool.title}`
@@ -24755,7 +24949,7 @@ const toolDefinitions = [
24755
24949
  handler: async (input, context, extra) => {
24756
24950
  const selectedSiteId = asNumber(input, "siteId");
24757
24951
  const site = await (await context.createClient({ siteId: selectedSiteId }, extra)).site.get();
24758
- context.setActiveSiteId(selectedSiteId, extra);
24952
+ await context.setActiveSiteId(selectedSiteId, extra);
24759
24953
  return {
24760
24954
  siteId: selectedSiteId,
24761
24955
  baseUrl: context.getBaseUrl(),
@@ -24792,7 +24986,7 @@ const toolDefinitions = [
24792
24986
  language: asOptionalString(input, "language"),
24793
24987
  placeholder: asOptionalBoolean(input, "placeholder")
24794
24988
  });
24795
- if (input.select !== false) context.setActiveSiteId(site.id, extra);
24989
+ if (input.select !== false) await context.setActiveSiteId(site.id, extra);
24796
24990
  return site;
24797
24991
  }
24798
24992
  },
@@ -24830,12 +25024,13 @@ const toolDefinitions = [
24830
25024
  {
24831
25025
  name: "neopress_template_mark",
24832
25026
  title: "Mark Site As Template",
24833
- 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.",
24834
25028
  inputSchema: {
24835
25029
  ...siteIdShape,
24836
25030
  name: z.string(),
24837
25031
  description: z.string().max(150).optional(),
24838
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."),
24839
25034
  previewImgUrls: z.array(z.string()).min(1),
24840
25035
  previewVideoUrl: httpUrlSchema.optional()
24841
25036
  },
@@ -24844,6 +25039,7 @@ const toolDefinitions = [
24844
25039
  name: asString(input, "name"),
24845
25040
  description: asOptionalString(input, "description"),
24846
25041
  referenceUrl: asString(input, "referenceUrl"),
25042
+ language: asString(input, "language"),
24847
25043
  previewImgUrls: input.previewImgUrls,
24848
25044
  previewVideoUrl: asOptionalString(input, "previewVideoUrl")
24849
25045
  });
@@ -25544,6 +25740,118 @@ const toolDefinitions = [
25544
25740
  });
25545
25741
  }
25546
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
+ },
25547
25855
  {
25548
25856
  name: "neopress_asset_upload_file",
25549
25857
  title: "Upload Local Asset File",
@@ -25663,19 +25971,65 @@ function createNeopressMcpServer(options = {}) {
25663
25971
  return server;
25664
25972
  }
25665
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
25666
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
+ }
25667
25998
  var MemoryRemoteSiteSelectionStore = class {
25668
25999
  siteIds = /* @__PURE__ */ new Map();
25669
- get(userKey) {
26000
+ async get(userKey) {
25670
26001
  return this.siteIds.get(userKey);
25671
26002
  }
25672
- set(userKey, siteId) {
26003
+ async set(userKey, siteId) {
25673
26004
  this.siteIds.set(userKey, siteId);
25674
26005
  }
25675
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
+ };
25676
26019
  function createMemoryRemoteSiteSelectionStore() {
25677
26020
  return new MemoryRemoteSiteSelectionStore();
25678
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
+ }
25679
26033
  function getUserKey(extra) {
25680
26034
  const userId = extra?.authInfo?.extra?.userId;
25681
26035
  if (typeof userId === "string" && userId) return `user:${userId}`;
@@ -25691,7 +26045,7 @@ function createRemoteToolContext(options) {
25691
26045
  const siteSelectionStore = options.siteSelectionStore ?? createMemoryRemoteSiteSelectionStore();
25692
26046
  return {
25693
26047
  async createClient(clientOptions = {}, extra) {
25694
- const siteId = clientOptions.siteId ?? siteSelectionStore.get(getUserKey(extra));
26048
+ const siteId = clientOptions.siteId ?? await siteSelectionStore.get(getUserKey(extra));
25695
26049
  const requireSite = clientOptions.requireSite ?? true;
25696
26050
  if (!siteId && requireSite) throw new Error("No site selected. Call neopress_site_select or pass siteId explicitly.");
25697
26051
  const sb = getSupabaseConfig();
@@ -25710,16 +26064,19 @@ function createRemoteToolContext(options) {
25710
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.");
25711
26065
  },
25712
26066
  fetch,
25713
- setActiveSiteId(siteId, extra) {
25714
- siteSelectionStore.set(getUserKey(extra), siteId);
26067
+ async setActiveSiteId(siteId, extra) {
26068
+ await siteSelectionStore.set(getUserKey(extra), siteId);
25715
26069
  },
25716
26070
  getBaseUrl() {
25717
26071
  return options.baseUrl;
25718
26072
  }
25719
26073
  };
25720
26074
  }
25721
- const DEFAULT_CLIENT_TTL_SECONDS = 2592e3;
25722
- 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
+ */
25723
26080
  var OAuthProtocolError = class extends Error {
25724
26081
  status;
25725
26082
  errorCode;
@@ -25730,130 +26087,11 @@ var OAuthProtocolError = class extends Error {
25730
26087
  this.errorCode = errorCode;
25731
26088
  }
25732
26089
  };
25733
- function toBase64Url(value) {
25734
- return Buffer.from(value, "utf-8").toString("base64url");
25735
- }
25736
- function fromBase64Url(value) {
25737
- return Buffer.from(value, "base64url").toString("utf-8");
25738
- }
25739
- function sign(value, secret) {
25740
- return createHmac("sha256", secret).update(value).digest("base64url");
25741
- }
25742
- function safeEqual(left, right) {
25743
- const leftBuffer = Buffer.from(left);
25744
- const rightBuffer = Buffer.from(right);
25745
- return leftBuffer.byteLength === rightBuffer.byteLength && timingSafeEqual(leftBuffer, rightBuffer);
25746
- }
25747
- function getOAuthClientSecret(explicitSecret) {
25748
- if (explicitSecret) return explicitSecret;
25749
- if (process.env.NODE_ENV === "production") throw new Error("NEOPRESS_MCP_OAUTH_CLIENT_SECRET is required in production.");
25750
- generatedClientSecret ??= randomBytes(32).toString("base64url");
25751
- return generatedClientSecret;
25752
- }
25753
- function normalizeRedirectUris(redirectUris) {
25754
- if (!Array.isArray(redirectUris)) throw new OAuthProtocolError(400, "invalid_client_metadata", "redirect_uris must be an array.");
25755
- const normalized = redirectUris.map((uri) => typeof uri === "string" ? uri.trim() : "").filter(Boolean);
25756
- if (normalized.length === 0) throw new OAuthProtocolError(400, "invalid_client_metadata", "At least one redirect_uri is required.");
25757
- for (const uri of normalized) try {
25758
- new URL(uri);
25759
- } catch {
25760
- throw new OAuthProtocolError(400, "invalid_redirect_uri", `Invalid redirect_uri: ${uri}`);
25761
- }
25762
- return [...new Set(normalized)];
25763
- }
25764
- function createClientRegistration(input, secret, nowSeconds = Math.floor(Date.now() / 1e3)) {
25765
- const redirectUris = normalizeRedirectUris(input.redirect_uris);
25766
- const payload = {
25767
- v: 1,
25768
- clientName: input.client_name,
25769
- redirectUris,
25770
- issuedAt: nowSeconds,
25771
- expiresAt: nowSeconds + DEFAULT_CLIENT_TTL_SECONDS
25772
- };
25773
- const encodedPayload = toBase64Url(JSON.stringify(payload));
25774
- return {
25775
- client_id: `${encodedPayload}.${sign(encodedPayload, secret)}`,
25776
- client_id_issued_at: payload.issuedAt,
25777
- client_name: payload.clientName,
25778
- redirect_uris: payload.redirectUris,
25779
- token_endpoint_auth_method: "none"
25780
- };
25781
- }
25782
- function verifyClientRegistration(clientId, secret, nowSeconds = Math.floor(Date.now() / 1e3)) {
25783
- const [encodedPayload, signature] = clientId.split(".");
25784
- if (!encodedPayload || !signature) throw new OAuthProtocolError(401, "invalid_client", "Invalid client_id.");
25785
- if (!safeEqual(signature, sign(encodedPayload, secret))) throw new OAuthProtocolError(401, "invalid_client", "Invalid client_id.");
25786
- let payload;
25787
- try {
25788
- payload = JSON.parse(fromBase64Url(encodedPayload));
25789
- } catch {
25790
- throw new OAuthProtocolError(401, "invalid_client", "Invalid client_id.");
25791
- }
25792
- if (payload.v !== 1 || !Array.isArray(payload.redirectUris)) throw new OAuthProtocolError(401, "invalid_client", "Invalid client_id.");
25793
- if (payload.expiresAt <= nowSeconds) throw new OAuthProtocolError(401, "invalid_client", "client_id has expired.");
25794
- return payload;
25795
- }
25796
- function requireParam(params, name) {
25797
- const value = params.get(name);
25798
- if (!value) throw new OAuthProtocolError(400, "invalid_request", `Missing required parameter: ${name}`);
25799
- return value;
25800
- }
25801
- function createAuthorizationRedirectUrl(requestUrl, config) {
25802
- if (requireParam(requestUrl.searchParams, "response_type") !== "code") throw new OAuthProtocolError(400, "unsupported_response_type", "Only response_type=code is supported.");
25803
- const client = verifyClientRegistration(requireParam(requestUrl.searchParams, "client_id"), config.clientSecret);
25804
- const redirectUri = requireParam(requestUrl.searchParams, "redirect_uri");
25805
- if (!client.redirectUris.includes(redirectUri)) throw new OAuthProtocolError(400, "invalid_redirect_uri", "redirect_uri is not registered for this client.");
25806
- const codeChallenge = requireParam(requestUrl.searchParams, "code_challenge");
25807
- if ((requestUrl.searchParams.get("code_challenge_method") || "S256") !== "S256") throw new OAuthProtocolError(400, "invalid_request", "Only S256 PKCE is supported.");
25808
- const redirectTo = new URL(redirectUri);
25809
- const state = requestUrl.searchParams.get("state");
25810
- if (state) redirectTo.searchParams.set("state", state);
25811
- const supabaseAuthorizeUrl = new URL(`${config.supabaseUrl}/auth/v1/authorize`);
25812
- supabaseAuthorizeUrl.searchParams.set("provider", config.provider);
25813
- supabaseAuthorizeUrl.searchParams.set("redirect_to", redirectTo.toString());
25814
- supabaseAuthorizeUrl.searchParams.set("code_challenge", codeChallenge);
25815
- supabaseAuthorizeUrl.searchParams.set("code_challenge_method", "S256");
25816
- return supabaseAuthorizeUrl.toString();
25817
- }
25818
- async function proxyTokenRequest(params, config) {
25819
- verifyClientRegistration(requireParam(params, "client_id"), config.clientSecret);
25820
- const grantType = requireParam(params, "grant_type");
25821
- let supabaseGrantType;
25822
- let body;
25823
- if (grantType === "authorization_code") {
25824
- supabaseGrantType = "pkce";
25825
- body = {
25826
- auth_code: requireParam(params, "code"),
25827
- code_verifier: requireParam(params, "code_verifier")
25828
- };
25829
- } else if (grantType === "refresh_token") {
25830
- supabaseGrantType = "refresh_token";
25831
- body = { refresh_token: requireParam(params, "refresh_token") };
25832
- } else throw new OAuthProtocolError(400, "unsupported_grant_type", "Only authorization_code and refresh_token grants are supported.");
25833
- const response = await (config.fetch ?? fetch)(`${config.supabaseUrl}/auth/v1/token?grant_type=${supabaseGrantType}`, {
25834
- method: "POST",
25835
- headers: {
25836
- "Content-Type": "application/json",
25837
- apikey: config.supabaseAnonKey
25838
- },
25839
- body: JSON.stringify(body)
25840
- });
25841
- const responseText = await response.text();
25842
- let data;
25843
- try {
25844
- data = responseText ? JSON.parse(responseText) : {};
25845
- } catch {
25846
- data = { message: responseText };
25847
- }
25848
- if (!response.ok) throw new OAuthProtocolError(response.status, "invalid_grant", typeof data.message === "string" ? data.message : "Token exchange failed.");
25849
- return {
25850
- access_token: data.access_token,
25851
- refresh_token: data.refresh_token,
25852
- expires_in: data.expires_in,
25853
- token_type: typeof data.token_type === "string" ? data.token_type : "bearer",
25854
- scope: "neopress:read neopress:write offline_access"
25855
- };
25856
- }
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
+ */
25857
26095
  async function verifySupabaseAccessToken(accessToken, config) {
25858
26096
  const response = await (config.fetch ?? fetch)(`${config.supabaseUrl}/auth/v1/user`, {
25859
26097
  method: "GET",
@@ -25882,11 +26120,25 @@ async function verifySupabaseAccessToken(accessToken, config) {
25882
26120
  //#region src/remote/http-server.ts
25883
26121
  const MCP_PATH = "/mcp";
25884
26122
  const DISABLED_REMOTE_TOOLS = ["neopress_asset_upload_file"];
25885
- const OAUTH_SCOPES = [
25886
- "neopress:read",
25887
- "neopress:write",
25888
- "offline_access"
25889
- ];
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
+ }
25890
26142
  function resolveOptions(options) {
25891
26143
  return {
25892
26144
  authMode: options.authMode ?? "oauth",
@@ -25899,11 +26151,10 @@ function resolveOptions(options) {
25899
26151
  ],
25900
26152
  fetch: options.fetch ?? fetch,
25901
26153
  host: options.host ?? "0.0.0.0",
25902
- oauthClientSecret: getOAuthClientSecret(options.oauthClientSecret),
25903
- oauthProvider: options.oauthProvider ?? "google",
26154
+ openaiAppsChallengeToken: options.openaiAppsChallengeToken,
25904
26155
  port: options.port ?? 4002,
25905
- publicUrl: options.publicUrl?.replace(/\/$/, ""),
25906
- siteSelectionStore: options.siteSelectionStore ?? createMemoryRemoteSiteSelectionStore(),
26156
+ publicUrl: resolvePublicUrl(options.publicUrl),
26157
+ siteSelectionStore: resolveSiteSelectionStore(options.siteSelectionStore),
25907
26158
  supabaseAnonKey: options.supabaseAnonKey ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imd4cmZ5am1zbWlha2lybXR6a212Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjIxNDM2MzQsImV4cCI6MjA3NzcxOTYzNH0.Xzx6Xg5O1EBXZ64_5BP7KhsVyafJ0GI-62HcDpq6WRk",
25908
26159
  supabaseUrl: (options.supabaseUrl ?? "https://gxrfyjmsmiakirmtzkmv.supabase.co").replace(/\/$/, "")
25909
26160
  };
@@ -25950,22 +26201,9 @@ function sendText(response, body, options = {}) {
25950
26201
  for (const [key, value] of Object.entries(options.headers ?? {})) response.setHeader(key, value);
25951
26202
  response.end(body);
25952
26203
  }
25953
- function sendOAuthError(response, error) {
25954
- if (error instanceof OAuthProtocolError) {
25955
- sendJson(response, {
25956
- error: error.errorCode,
25957
- error_description: error.message
25958
- }, { status: error.status });
25959
- return;
25960
- }
25961
- sendJson(response, {
25962
- error: "server_error",
25963
- error_description: error instanceof Error ? error.message : "Unexpected OAuth error."
25964
- }, { status: 500 });
25965
- }
25966
26204
  function sendUnauthorized(request, response, options) {
25967
- const issuer = getRequestBaseUrl(request, options);
25968
- 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"`);
25969
26207
  sendJson(response, {
25970
26208
  error: "unauthorized",
25971
26209
  error_description: "A valid Neopress OAuth bearer token is required."
@@ -25974,128 +26212,30 @@ function sendUnauthorized(request, response, options) {
25974
26212
  function readBearerToken(request) {
25975
26213
  return (getHeaderValue(request.headers.authorization)?.match(/^Bearer\s+(.+)$/i))?.[1]?.trim() || null;
25976
26214
  }
25977
- async function readBody(request) {
25978
- const chunks = [];
25979
- let size = 0;
25980
- for await (const chunk of request) {
25981
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
25982
- size += buffer.byteLength;
25983
- if (size > 1048576) throw new OAuthProtocolError(413, "invalid_request", "Request body is too large.");
25984
- chunks.push(buffer);
25985
- }
25986
- return Buffer.concat(chunks).toString("utf-8");
25987
- }
25988
- async function readRequestParams(request) {
25989
- const body = await readBody(request);
25990
- if ((getHeaderValue(request.headers["content-type"]) ?? "").includes("application/json")) {
25991
- let parsed;
25992
- try {
25993
- parsed = body ? JSON.parse(body) : {};
25994
- } catch {
25995
- throw new OAuthProtocolError(400, "invalid_request", "Invalid JSON body.");
25996
- }
25997
- const params = new URLSearchParams();
25998
- for (const [key, value] of Object.entries(parsed)) if (Array.isArray(value)) {
25999
- for (const item of value) if (item !== void 0 && item !== null) params.append(key, String(item));
26000
- } else if (value !== void 0 && value !== null) params.set(key, String(value));
26001
- return params;
26002
- }
26003
- return new URLSearchParams(body);
26004
- }
26005
- 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) {
26006
26227
  return {
26007
- resource: `${issuer}${MCP_PATH}`,
26008
- authorization_servers: [issuer],
26228
+ resource: `${resourceBaseUrl}${MCP_PATH}`,
26229
+ authorization_servers: [`${options.supabaseUrl}/auth/v1`],
26009
26230
  bearer_methods_supported: ["header"],
26010
- scopes_supported: OAUTH_SCOPES,
26011
26231
  resource_documentation: "https://app.neopress.ai"
26012
26232
  };
26013
26233
  }
26014
- function authorizationServerMetadata(issuer) {
26015
- return {
26016
- issuer,
26017
- authorization_endpoint: `${issuer}/oauth/authorize`,
26018
- token_endpoint: `${issuer}/oauth/token`,
26019
- registration_endpoint: `${issuer}/oauth/register`,
26020
- response_types_supported: ["code"],
26021
- grant_types_supported: ["authorization_code", "refresh_token"],
26022
- token_endpoint_auth_methods_supported: ["none"],
26023
- code_challenge_methods_supported: ["S256"],
26024
- scopes_supported: OAUTH_SCOPES,
26025
- resource_parameter_supported: true
26026
- };
26027
- }
26028
- function parseRedirectUrisParam(value) {
26029
- if (!value) return [];
26030
- if (!value.startsWith("[")) return [value];
26031
- try {
26032
- const parsed = JSON.parse(value);
26033
- return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [];
26034
- } catch {
26035
- throw new OAuthProtocolError(400, "invalid_client_metadata", "redirect_uris must be a string or JSON array of strings.");
26036
- }
26037
- }
26038
- async function handleRegister(request, response, options) {
26039
- if (request.method !== "POST") {
26040
- sendJson(response, { error: "method_not_allowed" }, { status: 405 });
26041
- return;
26042
- }
26043
- try {
26044
- const params = await readRequestParams(request);
26045
- const redirectUris = params.getAll("redirect_uris");
26046
- const parsed = params.get("redirect_uris");
26047
- sendJson(response, createClientRegistration({
26048
- client_name: params.get("client_name") ?? void 0,
26049
- redirect_uris: redirectUris.length > 1 ? redirectUris : parseRedirectUrisParam(parsed)
26050
- }, options.oauthClientSecret), { status: 201 });
26051
- } catch (error) {
26052
- sendOAuthError(response, error);
26053
- }
26054
- }
26055
- function handleAuthorize(request, response, options) {
26056
- if (request.method !== "GET") {
26057
- sendJson(response, { error: "method_not_allowed" }, { status: 405 });
26058
- return;
26059
- }
26060
- try {
26061
- const authorizationUrl = createAuthorizationRedirectUrl(getRequestUrl(request, options), {
26062
- clientSecret: options.oauthClientSecret,
26063
- fetch: options.fetch,
26064
- issuer: getRequestBaseUrl(request, options),
26065
- provider: options.oauthProvider,
26066
- supabaseAnonKey: options.supabaseAnonKey,
26067
- supabaseUrl: options.supabaseUrl
26068
- });
26069
- response.statusCode = 302;
26070
- response.setHeader("Location", authorizationUrl);
26071
- response.end();
26072
- } catch (error) {
26073
- sendOAuthError(response, error);
26074
- }
26075
- }
26076
- async function handleToken(request, response, options) {
26077
- if (request.method !== "POST") {
26078
- sendJson(response, { error: "method_not_allowed" }, { status: 405 });
26079
- return;
26080
- }
26081
- try {
26082
- sendJson(response, await proxyTokenRequest(await readRequestParams(request), {
26083
- clientSecret: options.oauthClientSecret,
26084
- fetch: options.fetch,
26085
- issuer: getRequestBaseUrl(request, options),
26086
- provider: options.oauthProvider,
26087
- supabaseAnonKey: options.supabaseAnonKey,
26088
- supabaseUrl: options.supabaseUrl
26089
- }));
26090
- } catch (error) {
26091
- sendOAuthError(response, error);
26092
- }
26093
- }
26094
26234
  async function authenticateMcpRequest(request, options) {
26095
26235
  if (options.authMode === "none") return {
26096
26236
  token: "",
26097
26237
  clientId: "anonymous",
26098
- scopes: OAUTH_SCOPES,
26238
+ scopes: [],
26099
26239
  extra: { userId: "anonymous" }
26100
26240
  };
26101
26241
  const accessToken = readBearerToken(request);
@@ -26108,7 +26248,7 @@ async function authenticateMcpRequest(request, options) {
26108
26248
  return {
26109
26249
  token: accessToken,
26110
26250
  clientId: "neopress-oauth",
26111
- scopes: OAUTH_SCOPES,
26251
+ scopes: [],
26112
26252
  expiresAt: user.expiresAt,
26113
26253
  extra: {
26114
26254
  email: user.email,
@@ -26126,7 +26266,7 @@ function createNeopressMcpHttpServer(rawOptions = {}) {
26126
26266
  return;
26127
26267
  }
26128
26268
  const url = getRequestUrl(request, options);
26129
- const issuer = getRequestBaseUrl(request, options);
26269
+ const resourceBaseUrl = getRequestBaseUrl(request, options);
26130
26270
  try {
26131
26271
  if (url.pathname === "/" || url.pathname === "/healthz" || url.pathname === "/livez") {
26132
26272
  sendText(response, "ok");
@@ -26137,23 +26277,15 @@ function createNeopressMcpHttpServer(rawOptions = {}) {
26137
26277
  return;
26138
26278
  }
26139
26279
  if (url.pathname === "/.well-known/oauth-protected-resource" || url.pathname === "/.well-known/oauth-protected-resource/mcp") {
26140
- sendJson(response, protectedResourceMetadata(issuer));
26141
- return;
26142
- }
26143
- if (url.pathname === "/.well-known/oauth-authorization-server" || url.pathname === "/.well-known/openid-configuration") {
26144
- sendJson(response, authorizationServerMetadata(issuer));
26145
- return;
26146
- }
26147
- if (url.pathname === "/oauth/register") {
26148
- await handleRegister(request, response, options);
26280
+ sendJson(response, protectedResourceMetadata(resourceBaseUrl, options));
26149
26281
  return;
26150
26282
  }
26151
- if (url.pathname === "/oauth/authorize") {
26152
- handleAuthorize(request, response, options);
26153
- return;
26154
- }
26155
- if (url.pathname === "/oauth/token") {
26156
- 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);
26157
26289
  return;
26158
26290
  }
26159
26291
  if (url.pathname === MCP_PATH) {