@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/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` |
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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.
|
|
58
|
-
|
|
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
|
|
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
|
-
| `
|
|
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
|
-
|
|
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",
|