@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/README.md +82 -18
- package/dist/bin.cjs +319 -11
- package/dist/http-bin.cjs +5400 -470
- package/dist/index.d.ts +14 -6
- package/dist/index.js +429 -297
- package/integrations/README.md +7 -7
- package/integrations/chatgpt/app-draft.md +111 -17
- package/integrations/claude-remote/connector-draft.md +53 -16
- package/integrations/submission-guide.md +60 -0
- package/package.json +3 -1
- package/scripts/check-dist-contract.mjs +28 -0
- package/scripts/deploy-cloud-run.sh +32 -13
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 {
|
|
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
|
-
|
|
25722
|
-
|
|
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
|
-
|
|
25734
|
-
|
|
25735
|
-
|
|
25736
|
-
|
|
25737
|
-
|
|
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
|
-
|
|
25886
|
-
|
|
25887
|
-
|
|
25888
|
-
|
|
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
|
-
|
|
25903
|
-
oauthProvider: options.oauthProvider ?? "google",
|
|
26154
|
+
openaiAppsChallengeToken: options.openaiAppsChallengeToken,
|
|
25904
26155
|
port: options.port ?? 4002,
|
|
25905
|
-
publicUrl: options.publicUrl
|
|
25906
|
-
siteSelectionStore: options.siteSelectionStore
|
|
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
|
|
25968
|
-
response.setHeader("WWW-Authenticate", `Bearer resource_metadata="${
|
|
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
|
-
|
|
25978
|
-
|
|
25979
|
-
|
|
25980
|
-
|
|
25981
|
-
|
|
25982
|
-
|
|
25983
|
-
|
|
25984
|
-
|
|
25985
|
-
|
|
25986
|
-
|
|
25987
|
-
|
|
25988
|
-
|
|
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: `${
|
|
26008
|
-
authorization_servers: [
|
|
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:
|
|
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:
|
|
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
|
|
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(
|
|
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 === "/
|
|
26152
|
-
|
|
26153
|
-
|
|
26154
|
-
|
|
26155
|
-
|
|
26156
|
-
|
|
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) {
|