sapiens-mcp 1.94.0 → 1.95.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.
@@ -0,0 +1,221 @@
1
+ import https from "node:https";
2
+ import dns from "node:dns";
3
+ import net from "node:net";
4
+ /**
5
+ * Download de URL que veio de um AGENTE (sourceUrl de upload, ingest, fonte).
6
+ * No transporte remoto o pedido sai do NOSSO servidor no Vercel: sem esta
7
+ * régua, "sobe essa imagem" vira porta pra ler serviço interno ou o metadata
8
+ * da cloud (169.254.169.254).
9
+ *
10
+ * Três furos que o `fetch` com `redirect: "follow"` deixava abertos:
11
+ * - o 3xx pra host privado: o fetch já tinha batido no destino quando o
12
+ * `res.url` era conferido. Aqui cada salto é conferido ANTES do pedido.
13
+ * - o nome público que resolve pra IP privado (127.0.0.1.nip.io): a régua de
14
+ * texto não vê. Aqui a conferência é no `lookup` do socket, sobre TODOS os
15
+ * endereços que o DNS devolve.
16
+ * - o DNS que troca de resposta entre a conferência e a conexão (rebinding):
17
+ * conferir num lookup e conectar noutro deixa a janela. Aqui o endereço
18
+ * conferido é o mesmo em que o socket conecta.
19
+ */
20
+ const MAX_REDIRECTS = 3;
21
+ // [rede, prefixo]. Tudo que não é internet pública: loopback, RFC 1918,
22
+ // link-local, CGNAT, documentação, benchmark, multicast e reservado.
23
+ const IPV4_FORA = [
24
+ ["0.0.0.0", 8],
25
+ ["10.0.0.0", 8],
26
+ ["100.64.0.0", 10],
27
+ ["127.0.0.0", 8],
28
+ ["169.254.0.0", 16],
29
+ ["172.16.0.0", 12],
30
+ ["192.0.0.0", 24],
31
+ ["192.0.2.0", 24],
32
+ ["192.168.0.0", 16],
33
+ ["198.18.0.0", 15],
34
+ ["198.51.100.0", 24],
35
+ ["203.0.113.0", 24],
36
+ ["224.0.0.0", 3], // multicast + reservado + broadcast (224/4 e 240/4)
37
+ ];
38
+ const IPV6_FORA = [
39
+ ["::", 128], // não especificado
40
+ ["::1", 128], // loopback
41
+ ["100::", 64], // descarte
42
+ ["2001:db8::", 32], // documentação
43
+ ["fc00::", 7], // ULA (a "rede privada" do IPv6)
44
+ ["fe80::", 10], // link-local
45
+ ["fec0::", 10], // site-local (obsoleto, ainda roteia em rede velha)
46
+ ["ff00::", 8], // multicast
47
+ ];
48
+ function ipv4Num(ip) {
49
+ return ip.split(".").reduce((n, o) => n * 256 + Number(o), 0);
50
+ }
51
+ /** Os 16 bytes de um IPv6 válido (cauda IPv4 pontuada inclusa). */
52
+ function ipv6Bytes(ip) {
53
+ let s = ip.toLowerCase().split("%")[0];
54
+ const cauda = s.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/);
55
+ if (cauda) {
56
+ const n = ipv4Num(cauda[2]);
57
+ s = `${cauda[1]}${(n >>> 16).toString(16)}:${(n & 0xffff).toString(16)}`;
58
+ }
59
+ const [cabeca, resto] = s.split("::");
60
+ const a = cabeca ? cabeca.split(":") : [];
61
+ const b = resto ? resto.split(":") : [];
62
+ const grupos = resto === undefined ? a : [...a, ...Array(8 - a.length - b.length).fill("0"), ...b];
63
+ return grupos.flatMap((g) => {
64
+ const v = parseInt(g, 16);
65
+ return [v >> 8, v & 0xff];
66
+ });
67
+ }
68
+ function dentroIPv6(bytes, rede, prefixo) {
69
+ const r = ipv6Bytes(rede);
70
+ for (let bit = 0; bit < prefixo; bit += 8) {
71
+ const resta = Math.min(8, prefixo - bit);
72
+ const mascara = (0xff << (8 - resta)) & 0xff;
73
+ if ((bytes[bit / 8] & mascara) !== (r[bit / 8] & mascara))
74
+ return false;
75
+ }
76
+ return true;
77
+ }
78
+ /**
79
+ * true pra endereço que não é internet pública. IPv6 que carrega um IPv4 dentro
80
+ * (::ffff:127.0.0.1, o NAT64 64:ff9b::/96, o compatível ::a.b.c.d) é julgado
81
+ * pelo IPv4 que carrega. String que não é IP volta true: na dúvida, fora.
82
+ */
83
+ export function isPrivateAddress(ip) {
84
+ const limpo = ip.replace(/^\[|\]$/g, "");
85
+ if (net.isIPv4(limpo)) {
86
+ const n = ipv4Num(limpo);
87
+ return IPV4_FORA.some(([rede, prefixo]) => {
88
+ const tamanho = 2 ** (32 - prefixo);
89
+ return Math.floor(n / tamanho) === Math.floor(ipv4Num(rede) / tamanho);
90
+ });
91
+ }
92
+ if (!net.isIPv6(limpo))
93
+ return true;
94
+ const b = ipv6Bytes(limpo);
95
+ const zeros = (de, ate) => b.slice(de, ate).every((x) => x === 0);
96
+ const mapeado = zeros(0, 10) && b[10] === 0xff && b[11] === 0xff;
97
+ const nat64 = b[0] === 0 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b && zeros(4, 12);
98
+ const compativel = zeros(0, 12) && !zeros(12, 16) && !(zeros(12, 15) && b[15] === 1);
99
+ if (mapeado || nat64 || compativel)
100
+ return isPrivateAddress(b.slice(12).join("."));
101
+ return IPV6_FORA.some(([rede, prefixo]) => dentroIPv6(b, rede, prefixo));
102
+ }
103
+ /**
104
+ * Recusa URL que não é https pra host público, pelo TEXTO: nome reservado e IP
105
+ * literal (que não passa por DNS, então o lookup abaixo não o veria). O nome
106
+ * comum passa aqui e é conferido no lookup do socket.
107
+ */
108
+ export function assertPublicHttpsUrl(raw) {
109
+ let u;
110
+ try {
111
+ u = new URL(raw);
112
+ }
113
+ catch {
114
+ throw new Error(`sourceUrl não é uma URL válida: ${raw}`);
115
+ }
116
+ if (u.protocol !== "https:") {
117
+ throw new Error("sourceUrl precisa ser https.");
118
+ }
119
+ const h = u.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
120
+ const reservado = h === "localhost" ||
121
+ h.endsWith(".localhost") ||
122
+ h.endsWith(".local") ||
123
+ h.endsWith(".internal");
124
+ if (reservado || (net.isIP(h) && isPrivateAddress(h))) {
125
+ throw new Error(`Host não permitido: ${u.hostname}`);
126
+ }
127
+ return u;
128
+ }
129
+ // O lookup do socket: resolve, recusa se QUALQUER endereço for privado e
130
+ // entrega ao socket exatamente os endereços conferidos. Com `all` (o
131
+ // autoSelectFamily do Node 20+ pede a lista) devolve a lista; sem, o primeiro.
132
+ const lookupPublico = (hostname, options, callback) => {
133
+ dns.lookup(hostname, { ...options, all: true }, (err, enderecos) => {
134
+ if (err)
135
+ return callback(err, "");
136
+ const privado = enderecos.find((e) => isPrivateAddress(e.address));
137
+ if (privado || !enderecos.length) {
138
+ const e = new Error(`Host não permitido: ${hostname} resolve pra ${privado?.address ?? "nada"}`);
139
+ e.code = "EHOSTPRIVADO";
140
+ return callback(e, "");
141
+ }
142
+ if (options.all)
143
+ return callback(null, enderecos);
144
+ callback(null, enderecos[0].address, enderecos[0].family);
145
+ });
146
+ };
147
+ /**
148
+ * GET em URL pública com redirect seguido NA MÃO (até 3 saltos, cada Location
149
+ * passa pela régua antes do pedido) e corpo com teto: o que passa de
150
+ * `maxBytes` é cortado no meio do download, não depois de ocupar a memória.
151
+ * `timeoutMs` vale pra cadeia inteira. Status não-2xx volta com ok=false pro
152
+ * chamador dar o próprio recado.
153
+ */
154
+ export async function fetchPublicHttps(raw, { timeoutMs, maxBytes }) {
155
+ const signal = AbortSignal.timeout(timeoutMs);
156
+ let url = assertPublicHttpsUrl(raw);
157
+ for (let salto = 0;; salto++) {
158
+ const res = await pedir(url, signal);
159
+ const location = res.headers.location;
160
+ if (res.statusCode && [301, 302, 303, 307, 308].includes(res.statusCode) && location) {
161
+ res.resume();
162
+ if (salto >= MAX_REDIRECTS) {
163
+ throw new Error(`Redirecionamento demais (mais de ${MAX_REDIRECTS}) a partir de ${raw}.`);
164
+ }
165
+ url = assertPublicHttpsUrl(new URL(location, url));
166
+ continue;
167
+ }
168
+ const status = res.statusCode ?? 0;
169
+ const base = {
170
+ status,
171
+ statusText: res.statusMessage ?? "",
172
+ contentType: res.headers["content-type"]?.split(";")[0]?.trim() || undefined,
173
+ url,
174
+ };
175
+ if (status < 200 || status >= 300) {
176
+ res.resume();
177
+ return { ...base, ok: false, bytes: Buffer.alloc(0) };
178
+ }
179
+ return { ...base, ok: true, bytes: await lerComTeto(res, maxBytes, signal) };
180
+ }
181
+ }
182
+ function pedir(url, signal) {
183
+ return new Promise((resolve, reject) => {
184
+ const req = https.request(url, {
185
+ method: "GET",
186
+ // Sem pool: socket reaproveitado não passaria de novo pelo lookup.
187
+ agent: false,
188
+ lookup: lookupPublico,
189
+ signal,
190
+ headers: { accept: "*/*", "user-agent": "sapiens-mcp" },
191
+ });
192
+ req.on("response", resolve);
193
+ req.on("error", (e) => reject(signal.aborted ? erroDePrazo(url) : e));
194
+ req.end();
195
+ });
196
+ }
197
+ function lerComTeto(res, maxBytes, signal) {
198
+ const teto = `${Math.round(maxBytes / 1024 / 1024)} MB`;
199
+ const declarado = Number(res.headers["content-length"]);
200
+ if (declarado > maxBytes) {
201
+ res.destroy();
202
+ return Promise.reject(new Error(`Arquivo grande demais: ${(declarado / 1024 / 1024).toFixed(1)} MB (teto ${teto}).`));
203
+ }
204
+ return new Promise((resolve, reject) => {
205
+ const partes = [];
206
+ let total = 0;
207
+ res.on("data", (parte) => {
208
+ total += parte.byteLength;
209
+ if (total > maxBytes) {
210
+ res.destroy(new Error(`Arquivo grande demais: passou de ${teto} no download.`));
211
+ return;
212
+ }
213
+ partes.push(parte);
214
+ });
215
+ res.on("end", () => resolve(Buffer.concat(partes)));
216
+ res.on("error", (e) => reject(signal.aborted ? erroDePrazo(null) : e));
217
+ });
218
+ }
219
+ function erroDePrazo(url) {
220
+ return new Error(`O download passou do prazo${url ? ` (${url.host})` : ""}.`);
221
+ }
package/dist/registry.js CHANGED
@@ -161,7 +161,7 @@ export const TOOLS = {
161
161
  handler: stockVideo,
162
162
  },
163
163
  sapiens_brand: {
164
- description: "Brand Sapiens (= Design System) — paleta + tipografia + voz + estilo de imagem + persona como FONTE ÚNICA de estilo (espelha o Estúdio de Brand do app). Sub-actions: list (oficiais curados + os custom do user, leve, grátis), get (1 brand completo por slug: voz + imageStyle + persona/logo; só oficial ou o próprio), generate (CRIA design system novo a partir de descrição em texto livre; Gemini monta tudo e já nasce com card premium gpt-image-2; 150 Sinapses pelo texto + o card no gpt-image-2-high a preço de catálogo, hoje 1350 Sinapses no total, reembolsa se falhar), refine (ajusta brand custom por feedback livre tipo 'fundo mais escuro'/'voz mais seca', ~75 Sinapses), reroll (regenera SÓ 1 peça voice|palette|imageStyle numa direção diferente, grátis), card ((re)gera o card premium, preço de catálogo do modelo), delete (apaga brand custom do próprio user), set_visibility (torna um brand custom do user público/privado: público = aparece no perfil + galeria da comunidade, qualquer logado adota), list_public (galeria de design systems PÚBLICOS da comunidade, opt-in pelos donos, com atribuição), adopt (clona um brand público/oficial numa cópia NOVA e PRIVADA na conta do user; persona é dropada), persona (pluga um personagem DO PRÓPRIO user como a cara recorrente das imagens do brand custom dele, por character=<slug|id> de sapiens_character action=list_mine, ou character=null pra tirar; as refs saem da galeria do personagem e o personagem precisa ter pelo menos uma imagem; grátis), favorite (marca, ou com isFavorite=false desmarca, um brand custom do user como o FAVORITO, o escolhido entre as tentativas de um projeto: sobe pro topo da list e dos seletores do app, as outras tentativas ficam guardadas; oficial não se favorita; grátis), import (TRANSCREVE um design system que JÁ EXISTE fora, de outro app, do Figma, do kit de um cliente: o que veio é o que grava, hex e fonte exatos, sem IA no meio, mesmo título e corpo na mesma família ou fonte fora do Google Fonts; grátis, o card nasce em SVG; o shape está no arg `brand`, e o retorno avisa em `warnings` o que pode não aparecer igual), logo (põe a logo no brand custom pelo ID da imagem na galeria do user, img_… de sapiens_gallery action=upload, ou handle=null pra tirar; grátis). Brand que a pessoa JÁ TEM vai por import, nunca por generate: o generate cria e reinterpreta. O personagem só APARECE na imagem quando a marca é persona: depois de plugar, gere com sapiens_image brandSlug=<slug> brandMark=persona. Qualquer conta logada; Sinapses saem do dono do sessionToken. Pra generate: converse com o user e monte uma description rica (vibe, cores, voz, públicos, refs; se ele colar amostra de texto dele, inclua pra a voz sair dali) ANTES de chamar. Confirme o custo (1350 Sinapses) antes de gerar. Valor explícito na descrição (hex, nome de fonte, nome) o generate respeita, mas pra copiar um brand inteiro o import é exato e de graça.",
164
+ description: "Brand Sapiens (= Design System) — paleta + tipografia + voz + estilo de imagem + persona como FONTE ÚNICA de estilo (espelha o Estúdio de Brand do app). Sub-actions: list (oficiais curados + os custom do user, leve, grátis), get (1 brand completo por slug: voz + imageStyle + persona/logo; só oficial ou o próprio), generate (CRIA design system novo a partir de descrição em texto livre; Gemini monta tudo e já nasce com card premium gpt-image-2; 150 Sinapses pelo texto + o card no gpt-image-2-high a preço de catálogo, hoje 1350 Sinapses no total, reembolsa se falhar), refine (ajusta brand custom por feedback livre tipo 'fundo mais escuro'/'voz mais seca', ~75 Sinapses), reroll (regenera SÓ 1 peça voice|palette|imageStyle numa direção diferente, grátis), card ((re)gera o card premium, preço de catálogo do modelo), delete (apaga brand custom do próprio user), set_visibility (torna um brand custom do user público/privado: público = aparece no perfil + galeria da comunidade, qualquer logado adota), list_public (galeria de design systems PÚBLICOS da comunidade, opt-in pelos donos, com atribuição), adopt (clona um brand público/oficial numa cópia NOVA e PRIVADA na conta do user; persona é dropada), persona (pluga um personagem DO PRÓPRIO user como a cara recorrente das imagens do brand custom dele, por character=<slug|id> de sapiens_character action=list_mine, ou character=null pra tirar; as refs saem da galeria do personagem e o personagem precisa ter pelo menos uma imagem; grátis), favorite (marca, ou com isFavorite=false desmarca, um brand custom do user como o FAVORITO, o escolhido entre as tentativas de um projeto: sobe pro topo da list e dos seletores do app, as outras tentativas ficam guardadas; oficial não se favorita; grátis), import (TRANSCREVE um design system que JÁ EXISTE fora, de outro app, do Figma, do kit de um cliente: o que veio é o que grava, hex e fonte exatos, sem IA no meio, mesmo título e corpo na mesma família ou fonte fora do Google Fonts; grátis, o card nasce em SVG; aceita o ARQUIVO DE DESIGN TOKENS como veio, DTCG ou Tokens Studio, mapeado por papel com o de-onde-saiu em `mapping`, ou o shape da casa; o retorno avisa em `warnings` o que pode não aparecer igual), font (sobe o ARQUIVO de uma fonte que não é do Google Fonts pro papel headline, body ou mono, por filePath, sourceUrl ou base64, até 2 MB, grátis; só fonte com licença pra web), logo (põe a logo no brand custom pelo ID da imagem na galeria do user, img_… de sapiens_gallery action=upload, ou handle=null pra tirar; grátis). Brand que a pessoa JÁ TEM vai por import, nunca por generate: o generate cria e reinterpreta. O personagem só APARECE na imagem quando a marca é persona: depois de plugar, gere com sapiens_image brandSlug=<slug> brandMark=persona. Qualquer conta logada; Sinapses saem do dono do sessionToken. Pra generate: converse com o user e monte uma description rica (vibe, cores, voz, públicos, refs; se ele colar amostra de texto dele, inclua pra a voz sair dali) ANTES de chamar. Confirme o custo (1350 Sinapses) antes de gerar. Valor explícito na descrição (hex, nome de fonte, nome) o generate respeita, mas pra copiar um brand inteiro o import é exato e de graça.",
165
165
  schema: brandSchema,
166
166
  handler: brand,
167
167
  },
package/dist/skills.js CHANGED
@@ -926,7 +926,7 @@ O nome no topo, a marca no canto da capa e o @ do fecho saem de \`theme.signatur
926
926
 
927
927
  O design system da pessoa veste a peça inteira: a cor (\`theme.brand\`), a fonte do título e do corpo (\`theme.fonts\`), a voz do texto e a assinatura. No \`generate\`, \`brandSlug\` escolhe um brand que não é o padrão da conta. A cor de DESTAQUE (kicker, número, palavra-chave) é a cor do brand que lê no fundo e se separa do texto; pra trocar, ponha o mesmo hex em \`accent\` e \`kicker\` no update.
928
928
 
929
- Ela já tem identidade fora daqui (outro app, Figma, o kit de um cliente)? Traga com \`sapiens_brand action=import\`, que grava hex e fonte exatos e é grátis. O \`generate\` cria um brand novo e reinterpreta.
929
+ Ela já tem identidade fora daqui (outro app, Figma, o kit de um cliente)? Traga com \`sapiens_brand action=import\`, que grava hex e fonte exatos e é grátis, e aceita o arquivo de design tokens do Figma como veio. O \`generate\` cria um brand novo e reinterpreta. Fonte que não é do Google Fonts (Fontshare, comprada) aparece na fonte da casa até alguém subir o arquivo dela: \`sapiens_brand action=font\`.
930
930
 
931
931
  ## Carrossel que vem de fora
932
932
 
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
- import { convexAction, convexQuery, convexMutation, getSessionToken, } from "../convexClient.js";
2
+ import { readFile } from "node:fs/promises";
3
+ import { convexAction, convexQuery, convexMutation, getSessionToken, isRemoteContext, } from "../convexClient.js";
4
+ import { fetchPublicHttps } from "../publicFetch.js";
3
5
  /**
4
6
  * Brand Sapiens (= Design System). Espelha o Estúdio de Brand do app: um Brand
5
7
  * é a FONTE ÚNICA de estilo (paleta, tipografia, voz, estilo de imagem,
@@ -42,6 +44,10 @@ import { convexAction, convexQuery, convexMutation, getSessionToken, } from "../
42
44
  * o kit de um cliente): o que veio é o que grava, sem IA no meio,
43
45
  * fonte e hex exatos. Grátis (card nasce em SVG). É a porta de
44
46
  * quem já tem brand; generate é a de quem quer um.
47
+ * - font: sobe o ARQUIVO de uma fonte do brand (a que não é do Google
48
+ * Fonts: Fontshare, comprada, do cliente) pro papel headline,
49
+ * body ou mono. Os bytes saem de filePath (stdio), sourceUrl
50
+ * (https público) ou base64. De graça, até 2 MB.
45
51
  * - logo: põe a logo no brand custom pelo ID da imagem na galeria do
46
52
  * user (img_…, de sapiens_gallery action=upload), ou tira
47
53
  * (handle=null). De graça.
@@ -71,6 +77,7 @@ export const brandSchema = z.object({
71
77
  "favorite",
72
78
  "import",
73
79
  "logo",
80
+ "font",
74
81
  ]),
75
82
  slug: z
76
83
  .string()
@@ -79,7 +86,8 @@ export const brandSchema = z.object({
79
86
  brand: z
80
87
  .any()
81
88
  .optional()
82
- .describe("Pra action=import: o design system que JÁ EXISTE, no shape do Brand da casa (o mesmo que action=get devolve). " +
89
+ .describe("Pra action=import: o design system que JÁ EXISTE. Serve o ARQUIVO DE DESIGN TOKENS como veio (DTCG com $value/$type, ou Tokens Studio com value/type: o servidor mapeia por papel lendo o caminho do token, color.brand.primary vira primary, color.text vira foreground, e devolve em `mapping` de onde saiu cada cor e fonte; aí mande o nome em `name`) " +
90
+ "OU o shape do Brand da casa (o mesmo que action=get devolve): " +
83
91
  "{ name (obrigatório, ≤40), tagline?, description?, " +
84
92
  "palette: { primary, secondary?, tertiary?, accent?, background, foreground, swatches?: [{name, hex}] }, " +
85
93
  "typography: { headline, body, mono? } (cada uma a família como string, ou {family, note}), " +
@@ -99,6 +107,26 @@ export const brandSchema = z.object({
99
107
  .nullable()
100
108
  .optional()
101
109
  .describe("Pra action=logo: o ID citável da imagem da logo na galeria do user (img_…). Suba antes com sapiens_gallery action=upload (PNG com fundo transparente é o ideal) e use o ID que voltar. null tira a logo."),
110
+ role: z
111
+ .enum(["headline", "body", "mono"])
112
+ .optional()
113
+ .describe("Pra action=font: o papel da fonte no brand (headline = título, body = corpo, mono = código)."),
114
+ family: z
115
+ .string()
116
+ .optional()
117
+ .describe("Pra action=font: o nome da família, como o CSS chama (ex: 'Clash Display'). Omita pra usar a família que o papel já tem no brand (o caso comum: o brand importado com Clash Display ganha o arquivo dela)."),
118
+ filePath: z
119
+ .string()
120
+ .optional()
121
+ .describe("Pra action=font: caminho ABSOLUTO do arquivo de fonte local (.woff2, .woff, .ttf, .otf). Só no MCP instalado (stdio); no remoto use sourceUrl."),
122
+ sourceUrl: z
123
+ .string()
124
+ .optional()
125
+ .describe("Pra action=font: URL https pública do arquivo de fonte (o servidor baixa e confere os bytes)."),
126
+ base64: z
127
+ .string()
128
+ .optional()
129
+ .describe("Pra action=font: o arquivo de fonte em base64, quando você já tem os bytes."),
102
130
  wordmark: z
103
131
  .string()
104
132
  .optional()
@@ -127,7 +155,7 @@ export const brandSchema = z.object({
127
155
  name: z
128
156
  .string()
129
157
  .optional()
130
- .describe("Pra action=generate: o nome do brand (opcional, ≤40 chars). Vem exatamente como mandado; sem ele o Gemini sugere um."),
158
+ .describe("Pra action=generate e import: o nome do brand (≤40 chars). Vem exatamente como mandado. No generate, sem ele o Gemini sugere um; no import, vence o nome que vier dentro do arquivo."),
131
159
  fromSlug: z
132
160
  .string()
133
161
  .optional()
@@ -247,7 +275,7 @@ export async function brand(args) {
247
275
  // -------- import: transcreve brand de fora, sem IA (grátis) --------
248
276
  if (args.action === "import") {
249
277
  let raw = args.brand;
250
- // Cliente que serializa `any` manda string JSON.
278
+ // Cliente que serializa `any` manda string JSON: confere aqui e segue.
251
279
  if (typeof raw === "string") {
252
280
  try {
253
281
  raw = JSON.parse(raw);
@@ -262,8 +290,11 @@ export async function brand(args) {
262
290
  }
263
291
  const res = await convexAction("customBrandsActions:mcpImportBrand", {
264
292
  sessionToken,
265
- brand: raw,
293
+ // Vai como TEXTO: o cliente do Convex recusa campo com `$` ($value,
294
+ // $type do design token) e fora do ASCII; o servidor faz o parse.
295
+ brand: JSON.stringify(raw),
266
296
  source: args.source?.trim() || undefined,
297
+ name: args.name?.trim() || undefined,
267
298
  });
268
299
  return {
269
300
  ...res,
@@ -272,7 +303,29 @@ export async function brand(args) {
272
303
  res.slug +
273
304
  " handle=img_…. Card premium (pago): action=card slug=" +
274
305
  res.slug +
275
- ". Usar no carrossel: no Tema do editor, ou o carrossel gerado com esse brand como padrão da conta.",
306
+ ". Carrossel nesse brand: sapiens_carrossel action=generate brandSlug=" +
307
+ res.slug +
308
+ " (ou escolha no Tema do editor).",
309
+ };
310
+ }
311
+ // -------- font: arquivo da fonte que não é do Google Fonts (grátis) --------
312
+ if (args.action === "font") {
313
+ if (!args.slug)
314
+ throw new Error("action=font exige slug de um brand custom SEU (pegue via action=list).");
315
+ if (!args.role)
316
+ throw new Error("action=font exige role=headline|body|mono (o papel da fonte no brand).");
317
+ const base64 = await resolveFontBytes(args);
318
+ const res = await convexAction("customBrandsActions:mcpUploadBrandFont", {
319
+ sessionToken,
320
+ slug: args.slug.trim(),
321
+ role: args.role,
322
+ ...(args.family?.trim() ? { family: args.family.trim() } : {}),
323
+ base64,
324
+ });
325
+ return {
326
+ ...res,
327
+ note: `Arquivo da ${res.family} no papel ${res.role}. O carrossel, o preview e a gaveta do brand passam a desenhar com ele. ` +
328
+ "Só suba fonte com licença pra uso na web (Google Fonts e Fontshare liberam; fonte comprada, confira a licença).",
276
329
  };
277
330
  }
278
331
  // -------- logo: pelo ID da imagem na galeria do user (grátis) --------
@@ -443,3 +496,26 @@ export async function brand(args) {
443
496
  };
444
497
  }
445
498
  }
499
+ // Os bytes do arquivo de fonte: arquivo local (stdio), URL pública (o fetch roda
500
+ // aqui, então com a mesma régua de host do upload da galeria) ou base64 pronto.
501
+ // O formato quem confere é o servidor, pelos bytes.
502
+ const FONT_MAX_BYTES = 2 * 1024 * 1024;
503
+ async function resolveFontBytes(args) {
504
+ if (args.base64?.trim())
505
+ return args.base64.trim().replace(/^data:[^;]+;base64,/, "");
506
+ if (args.filePath) {
507
+ if (isRemoteContext())
508
+ throw new Error("filePath só funciona no MCP instalado (stdio). No remoto, mande sourceUrl.");
509
+ const buf = await readFile(args.filePath);
510
+ if (buf.byteLength > FONT_MAX_BYTES)
511
+ throw new Error("Arquivo de fonte grande demais (teto 2 MB). Prefira o .woff2.");
512
+ return buf.toString("base64");
513
+ }
514
+ if (args.sourceUrl) {
515
+ const res = await fetchPublicHttps(args.sourceUrl, { timeoutMs: 30_000, maxBytes: FONT_MAX_BYTES });
516
+ if (!res.ok)
517
+ throw new Error(`Baixar a fonte falhou: HTTP ${res.status}`);
518
+ return res.bytes.toString("base64");
519
+ }
520
+ throw new Error("action=font exige o arquivo: filePath (local, stdio), sourceUrl (https) ou base64.");
521
+ }
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { createHash } from "node:crypto";
4
4
  import { convexAction, convexMutation, convexQuery, describeConvexError, getSessionToken, isRemoteContext, } from "../convexClient.js";
5
+ import { assertPublicHttpsUrl, fetchPublicHttps } from "../publicFetch.js";
5
6
  /**
6
7
  * Galeria do usuário: browse, publicação e UPLOAD de peça de fora.
7
8
  * Wrapper sobre `desktopMcp.galleryList` / `galleryGet` / `gallerySetPublic` e
@@ -208,39 +209,6 @@ function mimeFromPath(p) {
208
209
  const ext = p.split("?")[0].split("#")[0].split(".").pop()?.toLowerCase();
209
210
  return ext ? EXT_MIME[ext] : undefined;
210
211
  }
211
- /**
212
- * Recusa host que não é internet pública. O `sourceUrl` vem de um agente, e no
213
- * transporte remoto o fetch roda no NOSSO servidor: sem isto, "sobe essa
214
- * imagem" viraria uma porta pra ler serviço interno / metadata da cloud.
215
- */
216
- function assertPublicHttpsUrl(raw) {
217
- let u;
218
- try {
219
- u = new URL(raw);
220
- }
221
- catch {
222
- throw new Error(`sourceUrl não é uma URL válida: ${raw}`);
223
- }
224
- if (u.protocol !== "https:") {
225
- throw new Error("sourceUrl precisa ser https.");
226
- }
227
- const h = u.hostname.toLowerCase().replace(/^\[|\]$/g, "");
228
- const isPrivate = h === "localhost" ||
229
- h.endsWith(".local") ||
230
- h.endsWith(".internal") ||
231
- h === "::1" ||
232
- /^127\./.test(h) ||
233
- /^0\./.test(h) ||
234
- /^10\./.test(h) ||
235
- /^192\.168\./.test(h) ||
236
- /^169\.254\./.test(h) ||
237
- /^172\.(1[6-9]|2\d|3[01])\./.test(h) ||
238
- /^f[cd][0-9a-f]{2}:/.test(h);
239
- if (isPrivate) {
240
- throw new Error(`Host não permitido no upload: ${u.hostname}`);
241
- }
242
- return u;
243
- }
244
212
  /** Resolve sourceUrl | filePath | base64 nos bytes prontos pro Convex. */
245
213
  async function resolveUploadBytes(args) {
246
214
  if (args.base64?.trim()) {
@@ -262,24 +230,15 @@ async function resolveUploadBytes(args) {
262
230
  }
263
231
  if (args.sourceUrl) {
264
232
  const u = assertPublicHttpsUrl(args.sourceUrl);
265
- const res = await fetch(u, {
266
- redirect: "follow",
267
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
233
+ const res = await fetchPublicHttps(u, {
234
+ timeoutMs: FETCH_TIMEOUT_MS,
235
+ maxBytes: MAX_UPLOAD_BYTES,
268
236
  });
269
237
  if (!res.ok) {
270
238
  throw new Error(`Baixar a imagem falhou: HTTP ${res.status} ${res.statusText}`);
271
239
  }
272
- // Revalida o destino final: um 3xx pra host privado seria o buraco que o
273
- // assertPublicHttpsUrl da URL inicial não vê.
274
- if (res.url)
275
- assertPublicHttpsUrl(res.url);
276
- const buf = Buffer.from(await res.arrayBuffer());
277
- if (buf.byteLength > MAX_UPLOAD_BYTES) {
278
- throw new Error(`Imagem grande demais: ${(buf.byteLength / 1024 / 1024).toFixed(1)} MB (teto 12 MB).`);
279
- }
280
- const headerMime = res.headers.get("content-type")?.split(";")[0]?.trim();
281
- const mimeType = args.mimeType || mimeFromPath(u.pathname) || headerMime || "image/png";
282
- return { base64: buf.toString("base64"), mimeType };
240
+ const mimeType = args.mimeType || mimeFromPath(u.pathname) || res.contentType || "image/png";
241
+ return { base64: res.bytes.toString("base64"), mimeType };
283
242
  }
284
243
  throw new Error("action=upload exige a peça: sourceUrl (https), filePath (local, stdio) ou base64.");
285
244
  }
@@ -324,20 +283,15 @@ async function resolveIngestBytes(args) {
324
283
  }
325
284
  if (args.sourceUrl) {
326
285
  const u = assertPublicHttpsUrl(args.sourceUrl);
327
- const res = await fetch(u, {
328
- redirect: "follow",
329
- signal: AbortSignal.timeout(INGEST_FETCH_TIMEOUT_MS),
286
+ const res = await fetchPublicHttps(u, {
287
+ timeoutMs: INGEST_FETCH_TIMEOUT_MS,
288
+ maxBytes: MAX_INGEST_VIDEO_BYTES,
330
289
  });
331
290
  if (!res.ok) {
332
291
  throw new Error(`Baixar o render falhou: HTTP ${res.status} ${res.statusText}. URL de render expira (Kie em 24h, Magnific no mesmo dia): se passou do prazo, baixe de novo no provedor ou mande o arquivo por filePath.`);
333
292
  }
334
- // Revalida o destino final: um 3xx pra host privado seria o buraco que o
335
- // assertPublicHttpsUrl da URL inicial não vê.
336
- if (res.url)
337
- assertPublicHttpsUrl(res.url);
338
- const headerMime = res.headers.get("content-type")?.split(";")[0]?.trim();
339
- const mimeType = args.mimeType || mimeFromPath(u.pathname) || headerMime || "image/png";
340
- const bytes = Buffer.from(await res.arrayBuffer());
293
+ const mimeType = args.mimeType || mimeFromPath(u.pathname) || res.contentType || "image/png";
294
+ const bytes = res.bytes;
341
295
  confereTeto(bytes, mimeType, "Render");
342
296
  return { bytes, mimeType, sourceKey: args.sourceUrl, sha256: hexDe(bytes) };
343
297
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sapiens-mcp",
3
- "version": "1.94.0",
3
+ "version": "1.95.0",
4
4
  "mcpName": "com.sapiensinteticos/sapiens",
5
5
  "description": "MCP server pra operar o Sapiens Sintéticos (sapiensinteticos.com) pelo Claude Code: gerar imagem, escrever artigo, voz, música e mais, na sua conta. Login pelo código de sapiensinteticos.com/conectar-claude.",
6
6
  "type": "module",