@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 CHANGED
@@ -39,23 +39,72 @@ Endpoints:
39
39
 
40
40
  | Method | Path | Purpose |
41
41
  | ------ | ----------------------------------------- | ------------------------------- |
42
- | `POST` | `/mcp` | MCP Streamable HTTP endpoint |
43
- | `GET` | `/.well-known/oauth-protected-resource/mcp` | OAuth protected resource metadata |
44
- | `GET` | `/.well-known/oauth-authorization-server` | OAuth authorization server metadata |
45
- | `POST` | `/oauth/register` | Dynamic public client registration |
46
- | `GET` | `/oauth/authorize` | Supabase OAuth PKCE redirect |
47
- | `POST` | `/oauth/token` | Supabase PKCE/refresh proxy |
42
+ | `POST` | `/mcp` | MCP Streamable HTTP endpoint (OAuth bearer required) |
43
+ | `GET` | `/.well-known/oauth-protected-resource/mcp` | RFC 9728 protected resource metadata (also served at `/.well-known/oauth-protected-resource`) |
48
44
  | `GET` | `/healthz` | Liveness probe |
49
45
  | `GET` | `/readyz` | Readiness probe |
50
46
 
51
- Remote MCP requires OAuth bearer tokens for `/mcp`. It exposes dynamic client
52
- registration and PKCE token exchange for clients that support remote MCP OAuth.
53
- The access token is then used against the Neopress first-party `/api/v1` API,
54
- so tenant membership remains enforced by the app API.
47
+ The server is a pure Resource Server (RS-only): the OAuth 2.1 Authorization
48
+ Server is Supabase Auth. Clients discover it from `authorization_servers` in
49
+ the protected resource metadata and run dynamic client registration
50
+ (`/auth/v1/oauth/clients/register`), authorization, and token exchange against
51
+ Supabase directly — this server hosts no OAuth routes of its own.
52
+ Unauthenticated `/mcp` requests get a 401 with a `WWW-Authenticate:
53
+ Bearer resource_metadata="..."` pointer. The resource metadata deliberately
54
+ omits `scopes_supported`: clients echo advertised scopes into their authorize
55
+ request, and Supabase rejects custom scopes.
56
+
57
+ Bearer tokens are verified live against Supabase (`GET /auth/v1/user`), so a
58
+ global logout invalidates them immediately. The access token is then used
59
+ against the Neopress first-party `/api/v1` API, so tenant membership remains
60
+ enforced by the app API.
61
+
62
+ ### OAuth consent flow
63
+
64
+ Consent is delegated by Supabase to the Neopress app (Authorization Path
65
+ `https://app.neopress.ai/oauth/consent`, configured in the Supabase dashboard):
66
+
67
+ 1. Client registers via Supabase DCR, then starts
68
+ `GET {SUPABASE_URL}/auth/v1/oauth/authorize` (PKCE, S256).
69
+ 2. Supabase redirects the browser to the app consent page with
70
+ `?authorization_id={id}`.
71
+ 3. The consent page (session-gated on the app origin) fetches the pending
72
+ authorization from `GET {SUPABASE_URL}/auth/v1/oauth/authorizations/{id}`
73
+ using the anon `apikey` plus the signed-in user's access token, and shows
74
+ the client name, account, and a static access description. If the
75
+ authorization was already approved, Supabase returns only `{redirect_url}`
76
+ and the page redirects immediately.
77
+ 4. Approve/deny posts to `POST .../oauth/authorizations/{id}/consent` with
78
+ `{"action":"approve"|"deny"}`; the browser follows the returned
79
+ `redirect_url` back to the client (deny carries `error=access_denied`).
80
+ 5. The client exchanges the code at the Supabase token endpoint. The MCP
81
+ server only ever sees the resulting bearer token.
55
82
 
56
83
  Remote MCP disables `neopress_asset_upload_file` because a hosted server cannot
57
- read the client's local filesystem. Use `neopress_asset_register` for public
58
- URLs, or use local stdio MCP for local file uploads.
84
+ read the client's local filesystem. Two remote-native paths cover the same
85
+ ground without pushing file bytes through the model's context:
86
+
87
+ | You have | Tool | What happens |
88
+ | --- | --- | --- |
89
+ | The bytes, and outbound network of your own | `neopress_asset_presign` → your own PUT → `neopress_asset_register` | You upload straight to storage; nothing crosses the conversation, so size and count are free |
90
+ | A public URL | `neopress_asset_import_url` | The server fetches it and stores our own copy, then registers it |
91
+ | The bytes, but no outbound network | `neopress_asset_upload_base64` | Bytes ride inside the tool call, over the connection your host already has |
92
+
93
+ `neopress_asset_upload_base64` is a deliberate fallback, not a default. Inline
94
+ bytes pass through the model's context (~90K tokens per MB), so it is capped at
95
+ 1MB decoded and should only be used where the presign PUT is refused — a
96
+ sandbox whose egress runs through a domain allowlist, for instance. The cap is
97
+ enforced on decoded bytes, since capping the base64 string would let ~33% more
98
+ through.
99
+
100
+ `neopress_asset_register` on its own only records an external URL — the image
101
+ breaks if the source moves. Prefer `neopress_asset_import_url` unless you
102
+ deliberately want to reference someone else's host.
103
+
104
+ `neopress_asset_import_url` fetches a caller-supplied URL server-side, so it
105
+ resolves and range-checks every hop (including redirects) against private and
106
+ link-local space before connecting, and caps the body at 50MB while streaming.
107
+ See `src/asset-import.ts`.
59
108
 
60
109
  ## Environment
61
110
 
@@ -64,11 +113,11 @@ URLs, or use local stdio MCP for local file uploads.
64
113
  | `PORT` | `4002` | Remote HTTP listen port |
65
114
  | `HOST` | `0.0.0.0` | Remote HTTP listen host |
66
115
  | `NEOPRESS_BASE_URL` | `https://app.neopress.ai` | Neopress API base URL |
67
- | `NEOPRESS_MCP_PUBLIC_URL` | request-derived | Public issuer/resource origin |
116
+ | `NEOPRESS_MCP_PUBLIC_URL` | request-derived in dev | Public resource origin for the protected resource metadata and 401 pointer; required when `NODE_ENV=production` |
117
+ | `UPSTASH_REDIS_REST_URL` | unset (in-memory store) | Upstash Redis for the active site selection store; required together with the token when `NODE_ENV=production` so selections persist across restarts and instances |
118
+ | `UPSTASH_REDIS_REST_TOKEN` | unset (in-memory store) | See `UPSTASH_REDIS_REST_URL` |
68
119
  | `NEOPRESS_MCP_CORS_ORIGINS` | local dev origins | Comma-separated allowlist or `*` |
69
- | `NEOPRESS_MCP_OAUTH_PROVIDER` | `google` | Supabase OAuth provider |
70
- | `NEOPRESS_MCP_OAUTH_CLIENT_SECRET` | generated at boot in non-production | Stable HMAC secret for signed dynamic client IDs; required when `NODE_ENV=production` |
71
- | `NEOPRESS_SUPABASE_URL` | Neopress Supabase project | Supabase Auth base URL |
120
+ | `NEOPRESS_SUPABASE_URL` | Neopress Supabase project | Supabase Auth base URL (authorization server issuer origin) |
72
121
  | `NEOPRESS_SUPABASE_ANON_KEY` | Neopress anon key | Supabase Auth anon key |
73
122
  | `NEOPRESS_MCP_AUTH_MODE` | `oauth` | `none` is dev/test only and rejected in production |
74
123
 
@@ -86,7 +135,6 @@ docker run --rm -p 8080:8080 \
86
135
  Deploy through Artifact Registry / Cloud Run:
87
136
 
88
137
  ```bash
89
- export NEOPRESS_MCP_OAUTH_CLIENT_SECRET="$(openssl rand -base64 32)"
90
138
  export NEOPRESS_MCP_PUBLIC_URL="https://mcp.neopress.ai"
91
139
  pnpm deploy:mcp
92
140
  ```
@@ -94,6 +142,22 @@ pnpm deploy:mcp
94
142
  The deploy script defaults to project `inblog-ver-2`, region `us-west1`,
95
143
  repository `neopress`, and service `neopress-mcp-server`.
96
144
 
145
+ ### CI deploy
146
+
147
+ `.github/workflows/deploy-mcp.yml` deploys automatically on pushes to `main`
148
+ that change the MCP Docker build closure (`packages/mcp`, `packages/sdk`,
149
+ `packages/shared`, `packages/analytics-core`, lockfile/workspace config),
150
+ detected via turbo affected; `workflow_dispatch` always deploys. The job runs
151
+ typecheck/lint, then `pnpm predeploy:mcp` (agent contract gate), authenticates
152
+ with Workload Identity Federation, and executes
153
+ `packages/mcp/scripts/deploy-cloud-run.sh`.
154
+
155
+ The deploy step maps GitHub secrets `UPSTASH_REDIS_REST_URL`,
156
+ `UPSTASH_REDIS_REST_TOKEN` and repository var `NEOPRESS_MCP_PUBLIC_URL` into
157
+ the Cloud Run service environment. Supabase OAuth Server settings (toggle,
158
+ DCR, Authorization Path) are configured in the Supabase dashboard per the
159
+ human runbook in `docs/plan/2026-07-28-remote-mcp-supabase-as-swap.md`.
160
+
97
161
  ## Official Client Drafts
98
162
 
99
163
  Submission-ready drafts live under `integrations/`:
@@ -113,4 +177,4 @@ pnpm dlx @anthropic-ai/mcpb pack packages/mcp/dist/mcpb/neopress
113
177
  ```
114
178
 
115
179
  Do not submit the remote connector drafts until the production MCP origin,
116
- legal/support URLs, review workspace, and real client OAuth scans are verified.
180
+ support URL, review workspace, and real client OAuth scans are verified.
package/dist/bin.cjs CHANGED
@@ -45,6 +45,8 @@ node_process = __toESM(node_process, 1);
45
45
  let node_fs = require("node:fs");
46
46
  let node_path = require("node:path");
47
47
  let node_os = require("node:os");
48
+ let node_dns_promises = require("node:dns/promises");
49
+ let node_net = require("node:net");
48
50
  Object.freeze({ status: "aborted" });
49
51
  function $constructor(name, initializer, params) {
50
52
  function init(inst, def) {
@@ -42469,6 +42471,7 @@ function serializeTemplate(t) {
42469
42471
  referenceUrl: t.reference_url,
42470
42472
  previewImgUrls: t.preview_img_urls,
42471
42473
  previewVideoUrl: t.preview_video_url ?? null,
42474
+ language: t.language ?? null,
42472
42475
  createdAt: t.created_at
42473
42476
  };
42474
42477
  }
@@ -43437,7 +43440,7 @@ var TemplatesEndpoint = class {
43437
43440
  if (siteIds.length === 0) return [];
43438
43441
  const namePrefix = params?.namePrefix?.trim();
43439
43442
  const limit = Math.min(Math.max(params?.limit ?? 100, 1), 500);
43440
- let query = this.client.db.from("templates").select("id, name, reference_url, preview_img_urls, description, created_at").in("id", siteIds);
43443
+ let query = this.client.db.from("templates").select("id, name, reference_url, preview_img_urls, description, language, created_at").in("id", siteIds);
43441
43444
  if (namePrefix) query = query.ilike("name", `${namePrefix}%`);
43442
43445
  if (params?.order) query = query.order("created_at", { ascending: params.order === "asc" });
43443
43446
  query = query.limit(limit);
@@ -43453,6 +43456,7 @@ var TemplatesEndpoint = class {
43453
43456
  referenceUrl: t.reference_url,
43454
43457
  previewImgUrls: t.preview_img_urls ?? null,
43455
43458
  description: t.description ?? null,
43459
+ language: t.language ?? null,
43456
43460
  createdAt: t.created_at
43457
43461
  };
43458
43462
  });
@@ -43855,7 +43859,7 @@ function getSiteId(siteIdOverride) {
43855
43859
  if (projectConfig.siteId) return projectConfig.siteId;
43856
43860
  return loadSession().activeSiteId;
43857
43861
  }
43858
- function setActiveSiteId(siteId) {
43862
+ async function setActiveSiteId(siteId) {
43859
43863
  saveSession({
43860
43864
  ...loadSession(),
43861
43865
  activeSiteId: siteId
@@ -43969,8 +43973,9 @@ Use these rules before generating pages, collection schemas, entries, forms, or
43969
43973
  - Reusable templates are normal Neopress sites marked by a \`templates\` metadata row.
43970
43974
  - \`templates.id\` equals \`sites.id\`.
43971
43975
  - Build layout, pages, collections, entries, forms, assets, compile, publish, and verify before marking a finished template.
43972
- - 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.
43976
+ - 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.
43973
43977
  - \`referenceUrl\` is a single http(s) provenance URL. \`description\` is an optional summary of the template (max 150 characters).
43978
+ - \`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.
43974
43979
  - Template metadata does not replace page TSX, CMS content, forms, or publish state.
43975
43980
  - Use \`neopress_template_delete\` only for explicit template-site hard deletion. It removes the metadata row and connected site.
43976
43981
 
@@ -43979,6 +43984,7 @@ Use these rules before generating pages, collection schemas, entries, forms, or
43979
43984
  - Dynamic pages require a collection, a page path like \`/blog/[slug]\`, a numeric \`collectionId\`, matching entries, entry publish, page compile, and site publish.
43980
43985
  - \`queryConfig\` is stored in page \`draftMeta.queryConfig\`.
43981
43986
  - Query labels become React props. Entry query results are flattened, so use \`post.body\` instead of \`post.data.body\`.
43987
+ - 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.
43982
43988
  - 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.
43983
43989
  - Use numeric \`collection_id\` filters. Runtime controls site, status, locale, deleted state, and published snapshots.
43984
43990
 
@@ -44038,6 +44044,198 @@ function toToolError(error) {
44038
44044
  }]
44039
44045
  };
44040
44046
  }
44047
+ var AssetImportError = class extends Error {
44048
+ constructor(message) {
44049
+ super(message);
44050
+ this.name = "AssetImportError";
44051
+ }
44052
+ };
44053
+ function ipv4ToInt(address) {
44054
+ const parts = address.split(".");
44055
+ if (parts.length !== 4) return null;
44056
+ let value = 0;
44057
+ for (const part of parts) {
44058
+ const octet = Number(part);
44059
+ if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null;
44060
+ value = value * 256 + octet;
44061
+ }
44062
+ return value;
44063
+ }
44064
+ /**
44065
+ * Blocks every address that is not routable on the public internet. Includes
44066
+ * the ranges that matter for cloud metadata (169.254.0.0/16) and for reaching
44067
+ * back into our own VPC.
44068
+ */
44069
+ function isBlockedAddress(address) {
44070
+ const family = (0, node_net.isIP)(address);
44071
+ if (family === 4) {
44072
+ const value = ipv4ToInt(address);
44073
+ if (value === null) return true;
44074
+ const inRange = (cidrBase, bits) => {
44075
+ const base = ipv4ToInt(cidrBase);
44076
+ if (base === null) return false;
44077
+ const mask = bits === 0 ? 0 : -1 << 32 - bits >>> 0;
44078
+ return (value & mask) === (base & mask);
44079
+ };
44080
+ 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);
44081
+ }
44082
+ if (family === 6) {
44083
+ const normalized = address.toLowerCase().split("%")[0] ?? "";
44084
+ const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
44085
+ if (mapped?.[1]) return isBlockedAddress(mapped[1]);
44086
+ if (normalized === "::" || normalized === "::1") return true;
44087
+ return normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb");
44088
+ }
44089
+ return true;
44090
+ }
44091
+ /**
44092
+ * Rejects non-public destinations. Resolving here (rather than trusting the
44093
+ * hostname) is what stops `metadata.internal`-style names and DNS entries that
44094
+ * deliberately point at private space.
44095
+ */
44096
+ async function assertPublicUrl(rawUrl, resolver = defaultResolver) {
44097
+ let url;
44098
+ try {
44099
+ url = new URL(rawUrl);
44100
+ } catch {
44101
+ throw new AssetImportError(`Not a valid URL: ${rawUrl}`);
44102
+ }
44103
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new AssetImportError(`Only http(s) URLs can be imported, got ${url.protocol}`);
44104
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
44105
+ const addresses = (0, node_net.isIP)(hostname) ? [hostname] : await resolver(hostname);
44106
+ if (addresses.length === 0) throw new AssetImportError(`Could not resolve ${url.hostname}`);
44107
+ for (const address of addresses) if (isBlockedAddress(address)) throw new AssetImportError(`Refusing to fetch ${url.hostname}: it resolves to the non-public address ${address}`);
44108
+ return url;
44109
+ }
44110
+ async function defaultResolver(hostname) {
44111
+ return (await (0, node_dns_promises.lookup)(hostname, { all: true }).catch(() => [])).map((record) => record.address);
44112
+ }
44113
+ /**
44114
+ * Fetches the URL with every hop validated, then reads the body under a hard
44115
+ * byte cap. `redirect: 'manual'` is deliberate: letting fetch follow redirects
44116
+ * would skip the address check on the hops in between.
44117
+ */
44118
+ async function fetchImportableAsset(rawUrl, options) {
44119
+ const maxBytes = options.maxBytes ?? 52428800;
44120
+ let currentUrl = rawUrl;
44121
+ for (let hop = 0; hop <= 3; hop += 1) {
44122
+ const url = await assertPublicUrl(currentUrl, options.resolver);
44123
+ const controller = new AbortController();
44124
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 3e4);
44125
+ let response;
44126
+ try {
44127
+ response = await options.fetch(url.toString(), {
44128
+ redirect: "manual",
44129
+ signal: controller.signal
44130
+ });
44131
+ } finally {
44132
+ clearTimeout(timer);
44133
+ }
44134
+ if (response.status >= 300 && response.status < 400) {
44135
+ const location = response.headers.get("location");
44136
+ if (!location) throw new AssetImportError(`Redirect from ${url.toString()} had no Location header`);
44137
+ currentUrl = new URL(location, url).toString();
44138
+ continue;
44139
+ }
44140
+ if (!response.ok) throw new AssetImportError(`Source responded ${response.status} ${response.statusText || ""}`.trim());
44141
+ const declaredLength = Number(response.headers.get("content-length"));
44142
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) throw new AssetImportError(`Source is ${declaredLength} bytes, over the ${maxBytes} byte limit`);
44143
+ return {
44144
+ bytes: await readCapped(response, maxBytes),
44145
+ contentType: (response.headers.get("content-type") || "").split(";")[0]?.trim() || "",
44146
+ finalUrl: url.toString()
44147
+ };
44148
+ }
44149
+ throw new AssetImportError(`Too many redirects (max 3)`);
44150
+ }
44151
+ /** Reads the body incrementally so a lying Content-Length cannot blow the cap. */
44152
+ async function readCapped(response, maxBytes) {
44153
+ const reader = response.body?.getReader();
44154
+ if (!reader) {
44155
+ const buffer = new Uint8Array(await response.arrayBuffer());
44156
+ if (buffer.byteLength > maxBytes) throw new AssetImportError(`Source exceeds the ${maxBytes} byte limit`);
44157
+ return buffer;
44158
+ }
44159
+ const chunks = [];
44160
+ let total = 0;
44161
+ for (;;) {
44162
+ const { done, value } = await reader.read();
44163
+ if (done) break;
44164
+ if (!value) continue;
44165
+ total += value.byteLength;
44166
+ if (total > maxBytes) {
44167
+ await reader.cancel().catch(() => {});
44168
+ throw new AssetImportError(`Source exceeds the ${maxBytes} byte limit`);
44169
+ }
44170
+ chunks.push(value);
44171
+ }
44172
+ const merged = new Uint8Array(total);
44173
+ let offset = 0;
44174
+ for (const chunk of chunks) {
44175
+ merged.set(chunk, offset);
44176
+ offset += chunk.byteLength;
44177
+ }
44178
+ return merged;
44179
+ }
44180
+ /**
44181
+ * Derives a storage-safe file name. The source path is only a hint — anything
44182
+ * outside the allowed character set is dropped so a crafted URL cannot steer
44183
+ * the object key.
44184
+ */
44185
+ function deriveImportFileName(finalUrl, contentType) {
44186
+ const sanitized = decodeURIComponent(new URL(finalUrl).pathname.split("/").pop() || "").replace(/[^\w.-]/g, "").replace(/^\.+/, "");
44187
+ if (sanitized && /\.[a-z0-9]{1,5}$/i.test(sanitized)) return sanitized.slice(0, 120);
44188
+ const extension = extensionForContentType(contentType);
44189
+ const stem = (sanitized || "imported-asset").slice(0, 100);
44190
+ return extension ? `${stem}.${extension}` : stem;
44191
+ }
44192
+ function extensionForContentType(contentType) {
44193
+ return {
44194
+ "image/avif": "avif",
44195
+ "image/gif": "gif",
44196
+ "image/jpeg": "jpg",
44197
+ "image/png": "png",
44198
+ "image/svg+xml": "svg",
44199
+ "image/webp": "webp",
44200
+ "video/mp4": "mp4",
44201
+ "application/pdf": "pdf",
44202
+ "text/csv": "csv"
44203
+ }[contentType.toLowerCase()] ?? null;
44204
+ }
44205
+ /**
44206
+ * Ceiling for the inline (base64) upload fallback. Enforced on the *decoded*
44207
+ * bytes: capping the base64 string instead would let a caller slip ~33% more
44208
+ * through, and the number that matters downstream is the object size.
44209
+ *
44210
+ * 1MB is a deliberate cost ceiling rather than a technical one. Inline bytes
44211
+ * traverse the model's context, so 1MB costs roughly 90K tokens — already
44212
+ * enough that a caller should reach for presign or URL import when it can.
44213
+ */
44214
+ const MAX_INLINE_UPLOAD_BYTES = 1048576;
44215
+ /**
44216
+ * Decodes a base64 payload, tolerating a `data:` URL wrapper since models
44217
+ * routinely produce one. Rejects anything that is not valid base64: Buffer
44218
+ * silently drops invalid characters, so a corrupted payload would otherwise
44219
+ * upload as a truncated file instead of failing.
44220
+ */
44221
+ function decodeInlineUpload(payload, maxBytes = MAX_INLINE_UPLOAD_BYTES) {
44222
+ const dataUrl = payload.match(/^data:([^;,]*)(;base64)?,(.*)$/s);
44223
+ const contentTypeHint = dataUrl?.[1]?.trim() || null;
44224
+ const base64 = (dataUrl?.[3] ?? payload).replace(/\s/g, "");
44225
+ if (base64.length === 0) throw new AssetImportError("base64Content is empty.");
44226
+ 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).");
44227
+ const approximateBytes = Math.floor(base64.length * 3 / 4);
44228
+ if (approximateBytes > maxBytes) throw new AssetImportError(oversizeMessage(approximateBytes, maxBytes));
44229
+ const bytes = new Uint8Array(Buffer.from(base64, "base64"));
44230
+ if (bytes.byteLength > maxBytes) throw new AssetImportError(oversizeMessage(bytes.byteLength, maxBytes));
44231
+ return {
44232
+ bytes,
44233
+ contentTypeHint
44234
+ };
44235
+ }
44236
+ function oversizeMessage(actual, maxBytes) {
44237
+ 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.`;
44238
+ }
44041
44239
  //#endregion
44042
44240
  //#region src/tools.ts
44043
44241
  const siteIdShape = { siteId: number().int().positive().optional() };
@@ -44150,11 +44348,7 @@ function getToolMeta(tool) {
44150
44348
  return {
44151
44349
  securitySchemes: [{
44152
44350
  type: "oauth2",
44153
- scopes: [
44154
- "neopress:read",
44155
- "neopress:write",
44156
- "offline_access"
44157
- ]
44351
+ scopes: []
44158
44352
  }],
44159
44353
  "openai/toolInvocation/invoking": `Running ${tool.title}`,
44160
44354
  "openai/toolInvocation/invoked": `Finished ${tool.title}`
@@ -44232,7 +44426,7 @@ const toolDefinitions = [
44232
44426
  handler: async (input, context, extra) => {
44233
44427
  const selectedSiteId = asNumber(input, "siteId");
44234
44428
  const site = await (await context.createClient({ siteId: selectedSiteId }, extra)).site.get();
44235
- context.setActiveSiteId(selectedSiteId, extra);
44429
+ await context.setActiveSiteId(selectedSiteId, extra);
44236
44430
  return {
44237
44431
  siteId: selectedSiteId,
44238
44432
  baseUrl: context.getBaseUrl(),
@@ -44269,7 +44463,7 @@ const toolDefinitions = [
44269
44463
  language: asOptionalString(input, "language"),
44270
44464
  placeholder: asOptionalBoolean(input, "placeholder")
44271
44465
  });
44272
- if (input.select !== false) context.setActiveSiteId(site.id, extra);
44466
+ if (input.select !== false) await context.setActiveSiteId(site.id, extra);
44273
44467
  return site;
44274
44468
  }
44275
44469
  },
@@ -44307,12 +44501,13 @@ const toolDefinitions = [
44307
44501
  {
44308
44502
  name: "neopress_template_mark",
44309
44503
  title: "Mark Site As Template",
44310
- 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.",
44504
+ 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.",
44311
44505
  inputSchema: {
44312
44506
  ...siteIdShape,
44313
44507
  name: string(),
44314
44508
  description: string().max(150).optional(),
44315
44509
  referenceUrl: httpUrlSchema,
44510
+ language: 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."),
44316
44511
  previewImgUrls: array(string()).min(1),
44317
44512
  previewVideoUrl: httpUrlSchema.optional()
44318
44513
  },
@@ -44321,6 +44516,7 @@ const toolDefinitions = [
44321
44516
  name: asString(input, "name"),
44322
44517
  description: asOptionalString(input, "description"),
44323
44518
  referenceUrl: asString(input, "referenceUrl"),
44519
+ language: asString(input, "language"),
44324
44520
  previewImgUrls: input.previewImgUrls,
44325
44521
  previewVideoUrl: asOptionalString(input, "previewVideoUrl")
44326
44522
  });
@@ -45021,6 +45217,118 @@ const toolDefinitions = [
45021
45217
  });
45022
45218
  }
45023
45219
  },
45220
+ {
45221
+ name: "neopress_asset_presign",
45222
+ title: "Get Presigned Asset Upload URL",
45223
+ 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.",
45224
+ inputSchema: {
45225
+ ...siteIdShape,
45226
+ fileName: string(),
45227
+ contentType: string().optional(),
45228
+ contentLength: number().int().positive(),
45229
+ folder: string().optional()
45230
+ },
45231
+ handler: async (input, context, extra) => {
45232
+ const client = await context.createClient({ siteId: siteId(input) }, extra);
45233
+ const fileName = (0, node_path.basename)(asString(input, "fileName"));
45234
+ const folder = asOptionalString(input, "folder") || "uploads";
45235
+ return client.assets.presign({
45236
+ fileKey: `${folder}/${Date.now()}-${fileName}`,
45237
+ contentType: asOptionalString(input, "contentType") || getContentType(fileName),
45238
+ contentLength: asNumber(input, "contentLength")
45239
+ });
45240
+ }
45241
+ },
45242
+ {
45243
+ name: "neopress_asset_import_url",
45244
+ title: "Import Asset From URL",
45245
+ 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.",
45246
+ inputSchema: {
45247
+ ...siteIdShape,
45248
+ sourceUrl: string().url(),
45249
+ folder: string().optional(),
45250
+ displayName: string().optional(),
45251
+ altText: string().optional(),
45252
+ description: string().optional(),
45253
+ tags: array(string()).optional(),
45254
+ metadata: optionalJsonObjectSchema
45255
+ },
45256
+ handler: async (input, context, extra) => {
45257
+ const client = await context.createClient({ siteId: siteId(input) }, extra);
45258
+ const fetched = await fetchImportableAsset(asString(input, "sourceUrl"), { fetch: context.fetch });
45259
+ const fileName = deriveImportFileName(fetched.finalUrl, fetched.contentType);
45260
+ const contentType = fetched.contentType || getContentType(fileName);
45261
+ const folder = asOptionalString(input, "folder") || "uploads";
45262
+ const { presignedUrl, publicUrl } = await client.assets.presign({
45263
+ fileKey: `${folder}/${Date.now()}-${fileName}`,
45264
+ contentType,
45265
+ contentLength: fetched.bytes.byteLength
45266
+ });
45267
+ const uploadResponse = await context.fetch(presignedUrl, {
45268
+ method: "PUT",
45269
+ headers: { "Content-Type": contentType },
45270
+ body: fetched.bytes
45271
+ });
45272
+ if (!uploadResponse.ok) throw new Error(`Asset upload failed: ${uploadResponse.status} ${uploadResponse.statusText}`);
45273
+ return client.assets.register({
45274
+ assetUrl: publicUrl,
45275
+ displayName: asOptionalString(input, "displayName") || fileName,
45276
+ mimeType: contentType,
45277
+ size: fetched.bytes.byteLength,
45278
+ altText: asOptionalString(input, "altText"),
45279
+ description: asOptionalString(input, "description"),
45280
+ tags: input.tags,
45281
+ folderPath: folder,
45282
+ metadata: input.metadata
45283
+ });
45284
+ }
45285
+ },
45286
+ {
45287
+ name: "neopress_asset_upload_base64",
45288
+ title: "Upload Asset Inline (base64)",
45289
+ 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.",
45290
+ inputSchema: {
45291
+ ...siteIdShape,
45292
+ fileName: string(),
45293
+ base64Content: string(),
45294
+ contentType: string().optional(),
45295
+ folder: string().optional(),
45296
+ displayName: string().optional(),
45297
+ altText: string().optional(),
45298
+ description: string().optional(),
45299
+ tags: array(string()).optional(),
45300
+ metadata: optionalJsonObjectSchema
45301
+ },
45302
+ handler: async (input, context, extra) => {
45303
+ const client = await context.createClient({ siteId: siteId(input) }, extra);
45304
+ const fileName = (0, node_path.basename)(asString(input, "fileName"));
45305
+ const { bytes, contentTypeHint } = decodeInlineUpload(asString(input, "base64Content"));
45306
+ const contentType = asOptionalString(input, "contentType") || contentTypeHint || getContentType(fileName);
45307
+ const folder = asOptionalString(input, "folder") || "uploads";
45308
+ const { presignedUrl, publicUrl } = await client.assets.presign({
45309
+ fileKey: `${folder}/${Date.now()}-${fileName}`,
45310
+ contentType,
45311
+ contentLength: bytes.byteLength
45312
+ });
45313
+ const uploadResponse = await context.fetch(presignedUrl, {
45314
+ method: "PUT",
45315
+ headers: { "Content-Type": contentType },
45316
+ body: bytes
45317
+ });
45318
+ if (!uploadResponse.ok) throw new Error(`Asset upload failed: ${uploadResponse.status} ${uploadResponse.statusText}`);
45319
+ return client.assets.register({
45320
+ assetUrl: publicUrl,
45321
+ displayName: asOptionalString(input, "displayName") || fileName,
45322
+ mimeType: contentType,
45323
+ size: bytes.byteLength,
45324
+ altText: asOptionalString(input, "altText"),
45325
+ description: asOptionalString(input, "description"),
45326
+ tags: input.tags,
45327
+ folderPath: folder,
45328
+ metadata: input.metadata
45329
+ });
45330
+ }
45331
+ },
45024
45332
  {
45025
45333
  name: "neopress_asset_upload_file",
45026
45334
  title: "Upload Local Asset File",