@avocadostudio-ai/mcp-server 0.29.0 → 0.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/mcp-server",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Avocado Studio MCP server — exposes page/block/discovery tools over the Model Context Protocol.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "dependencies": {
38
38
  "@modelcontextprotocol/sdk": "^1.29.0",
39
39
  "zod": "^4.3.6",
40
- "@avocadostudio-ai/shared": "^0.29.0"
40
+ "@avocadostudio-ai/shared": "^0.30.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^22.13.10",
@@ -1,9 +0,0 @@
1
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { buildPageDoc } from "@avocadostudio-ai/migration-sdk";
3
- export declare function checkUrl(raw: string, allowLocalhost: boolean): {
4
- url: URL;
5
- } | {
6
- error: string;
7
- };
8
- export declare function registerScopeTools(server: McpServer): void;
9
- export declare function report(url: URL, page: ReturnType<typeof buildPageDoc>["page"], decisions: ReturnType<typeof buildPageDoc>["decisions"], includePage: boolean): string;
@@ -1,132 +0,0 @@
1
- import { z } from "zod";
2
- import { extractSections, buildPageDoc } from "@avocadostudio-ai/migration-sdk";
3
- /*
4
- * "What would my site look like in Avocado?" — answered before anybody wires
5
- * anything.
6
- *
7
- * This is the question every adopter asks first and the one the product could
8
- * not answer: the only route to a reply was a week of integration work. The
9
- * pieces existed — the section classifier and the page mapper — with no surface
10
- * in front of them.
11
- *
12
- * It reads. It never writes: no session, no draft, no orchestrator call. What
13
- * comes back is a proposal a person accepts or throws away, which is the only
14
- * safe shape for a tool whose input is a URL somebody typed.
15
- *
16
- * No model is involved, so it is free, fast, and gives the same answer twice.
17
- * That last property is the point: an agent can re-run it after a change and
18
- * diff the result, which it cannot do with anything that samples.
19
- */
20
- /**
21
- * Hosts a URL may not resolve to unless the caller says so.
22
- *
23
- * This server accepts a URL from a model and fetches it. Unguarded, that is a
24
- * request forgery primitive pointed at whatever the server can reach — cloud
25
- * metadata endpoints first. Local addresses are still reachable with
26
- * `allowLocalhost: true`, because scoping a site you are running on your own
27
- * machine is a real thing to want and the caller saying so makes it a choice.
28
- */
29
- const BLOCKED_HOST = [
30
- /^localhost$/i,
31
- /^127\./,
32
- /^0\./,
33
- /^10\./,
34
- /^192\.168\./,
35
- /^172\.(1[6-9]|2\d|3[01])\./,
36
- /^169\.254\./, // link-local, which is where cloud metadata lives
37
- /^\[?::1\]?$/,
38
- /\.local$/i,
39
- /\.internal$/i,
40
- ];
41
- export function checkUrl(raw, allowLocalhost) {
42
- let url;
43
- try {
44
- url = new URL(raw);
45
- }
46
- catch {
47
- return { error: `Not a URL: ${raw}. Pass an absolute http(s) URL, for example https://example.com/about.` };
48
- }
49
- if (url.protocol !== "http:" && url.protocol !== "https:") {
50
- return { error: `Only http and https are supported; got ${url.protocol}` };
51
- }
52
- if (!allowLocalhost && BLOCKED_HOST.some((p) => p.test(url.hostname))) {
53
- return {
54
- error: `Refusing to fetch ${url.hostname}: it is a local or private address. ` +
55
- `If you meant to scope a site running on this machine, pass allowLocalhost: true.`,
56
- };
57
- }
58
- return { url };
59
- }
60
- export function registerScopeTools(server) {
61
- server.tool("avocado-scope-url", "Read a live web page and report what it would become as Avocado blocks: how many sections it has, which built-in block each one maps to, and which sections could not be mapped and why. Read-only — it fetches the URL and returns a proposal, and writes nothing. Call this before planning a migration or an integration, and to answer 'what would my site look like in Avocado' without wiring anything up. Deterministic and free: no model is involved, so the same page always gives the same answer.", {
62
- url: z.string().describe("Absolute http(s) URL of the page to scope, e.g. https://example.com/about"),
63
- allowLocalhost: z
64
- .boolean()
65
- .optional()
66
- .describe("Permit local and private addresses. Default false, because this server fetches whatever URL it is given. Set it to scope a site running on this machine."),
67
- includePage: z
68
- .boolean()
69
- .optional()
70
- .describe("Include the full proposed PageDoc as JSON. Default false — the summary is what a person reads, and the document can be large."),
71
- }, async ({ url: raw, allowLocalhost = false, includePage = false }) => {
72
- const checked = checkUrl(raw, allowLocalhost);
73
- if ("error" in checked) {
74
- return { content: [{ type: "text", text: checked.error }], isError: true };
75
- }
76
- const url = checked.url;
77
- let html;
78
- try {
79
- const res = await fetch(url, {
80
- headers: { "User-Agent": "AvocadoStudio/scope (+https://docs.avocadostudio.dev)" },
81
- redirect: "follow",
82
- signal: AbortSignal.timeout(20_000),
83
- });
84
- if (!res.ok) {
85
- return {
86
- content: [{ type: "text", text: `${url} answered ${res.status} ${res.statusText}.` }],
87
- isError: true,
88
- };
89
- }
90
- html = await res.text();
91
- }
92
- catch (err) {
93
- const reason = err instanceof Error ? err.message : String(err);
94
- return { content: [{ type: "text", text: `Could not fetch ${url}: ${reason}` }], isError: true };
95
- }
96
- const sections = extractSections(html, url.toString());
97
- const { page, decisions } = buildPageDoc({
98
- slug: url.pathname || "/",
99
- title: html.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() || url.hostname,
100
- // The caller stamps the page when it decides to keep it. Scoping is a
101
- // read, and a mapper that reads a clock is not deterministic.
102
- updatedAt: "1970-01-01T00:00:00.000Z",
103
- sections,
104
- });
105
- return { content: [{ type: "text", text: report(url, page, decisions, includePage) }] };
106
- });
107
- }
108
- export function report(url, page, decisions, includePage) {
109
- const counts = new Map();
110
- for (const b of page.blocks)
111
- counts.set(b.type, (counts.get(b.type) ?? 0) + 1);
112
- const lines = [
113
- `${url}`,
114
- `${decisions.length} sections found, ${page.blocks.length} mapped to blocks.`,
115
- "",
116
- "section becomes note",
117
- ];
118
- for (const d of decisions) {
119
- const becomes = d.mappedTo ?? "(dropped)";
120
- const note = d.reason ? (d.mappedTo === null ? d.reason : `was ${d.downgradedFrom} — ${d.reason}`) : "";
121
- lines.push(`${String(d.sectionIndex).padEnd(8)} ${becomes.padEnd(16)} ${note}`);
122
- }
123
- lines.push("", `Result: ${[...counts].map(([t, n]) => `${t}×${n}`).join(", ") || "(nothing mappable)"}`);
124
- const poor = page.blocks.filter((b) => b.type === "RichText").length;
125
- if (page.blocks.length > 0 && poor / page.blocks.length > 0.5) {
126
- lines.push("", "More than half this page came out as RichText, which means the structure did not survive.", "That is usually a sign the page's sections are worth declaring as the site's own block", "types rather than mapped onto the built-ins — see the avocado-blocks skill.");
127
- }
128
- lines.push("", "This is a proposal. Nothing was written, no session was created, and the page's", "updatedAt is a placeholder — stamp it when you decide to keep it.");
129
- if (includePage)
130
- lines.push("", JSON.stringify(page, null, 2));
131
- return lines.join("\n");
132
- }