@cancia/mcp 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Antonino Dell'Albani
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,45 @@
1
+ /** One element that received (or would receive) a data-cms attribute. */
2
+ interface AnnotationTag {
3
+ /** The i18n key extracted from the t("…") call. */
4
+ key: string;
5
+ /** Tag name of the element that was annotated (e.g. "h1"). */
6
+ tag: string;
7
+ /** 1-based line number of the element's opening tag in the source. */
8
+ line: number;
9
+ }
10
+ /**
11
+ * A t() call we deliberately did not annotate, with the reason. The agent
12
+ * surfaces these so a human can decide whether any need manual handling.
13
+ */
14
+ interface AnnotationSkip {
15
+ key: string | null;
16
+ /** 1-based line number where the skipped call appears. */
17
+ line: number;
18
+ reason: "attribute" | "mixed-children" | "already-annotated" | "non-literal-key" | "unlocatable-tag";
19
+ }
20
+ interface AnnotateResult {
21
+ /** The rewritten source. Identical to input when nothing changed. */
22
+ code: string;
23
+ /** True when `code` differs from the input. */
24
+ changed: boolean;
25
+ /** Elements that got a data-cms attribute. */
26
+ tagged: AnnotationTag[];
27
+ /** t() calls intentionally left alone, with reasons. */
28
+ skipped: AnnotationSkip[];
29
+ }
30
+ interface AnnotateOptions {
31
+ /**
32
+ * Name of the translation function to match. Defaults to "t" — the
33
+ * convention used by useTranslations(lang). Set this if a project aliases
34
+ * it (e.g. "translate").
35
+ */
36
+ tFunction?: string;
37
+ }
38
+
39
+ /**
40
+ * Annotate Astro/JSX source: add data-cms="KEY" to every element whose only
41
+ * meaningful child is a t("KEY") call.
42
+ */
43
+ declare function annotateSource(source: string, opts?: AnnotateOptions): Promise<AnnotateResult>;
44
+
45
+ export { type AnnotateOptions, type AnnotateResult, type AnnotationSkip, type AnnotationTag, annotateSource };
@@ -0,0 +1,6 @@
1
+ import {
2
+ annotateSource
3
+ } from "../chunk-4DOTVUP2.js";
4
+ export {
5
+ annotateSource
6
+ };
@@ -0,0 +1,167 @@
1
+ // src/annotate/index.ts
2
+ import { parse } from "@astrojs/compiler";
3
+ function isElement(n) {
4
+ return n.type === "element" || n.type === "component" || n.type === "custom-element";
5
+ }
6
+ function expressionSource(node) {
7
+ return (node.children ?? []).map((c) => c.value ?? "").join("");
8
+ }
9
+ function matchTCall(src, tFn) {
10
+ const trimmed = src.trim();
11
+ const re = new RegExp(`^${escapeRegex(tFn)}\\(\\s*(["'])([^"']+)\\1\\s*\\)$`);
12
+ const m = trimmed.match(re);
13
+ return m ? m[2] : null;
14
+ }
15
+ function isNonLiteralTCall(src, tFn) {
16
+ const trimmed = src.trim();
17
+ return new RegExp(`^${escapeRegex(tFn)}\\s*\\(`).test(trimmed) && matchTCall(src, tFn) === null;
18
+ }
19
+ function escapeRegex(s) {
20
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
21
+ }
22
+ function meaningfulChildren(node) {
23
+ return (node.children ?? []).filter((c) => {
24
+ if (c.type === "text") return (c.value ?? "").trim() !== "";
25
+ if (c.type === "comment") return false;
26
+ return true;
27
+ });
28
+ }
29
+ function findOpenAngle(node, src) {
30
+ const reported = node.position?.start?.offset;
31
+ if (reported === void 0) return null;
32
+ const name = node.name;
33
+ const floor = Math.max(0, reported - 4096);
34
+ for (let i = Math.min(reported, src.length - 1); i >= floor; i--) {
35
+ if (src[i] !== "<") continue;
36
+ const after = src.slice(i + 1, i + 1 + name.length);
37
+ if (after !== name) continue;
38
+ const boundary = src[i + 1 + name.length];
39
+ if (boundary === void 0 || /[\s/>]/.test(boundary)) return i;
40
+ }
41
+ return null;
42
+ }
43
+ function hasDataCmsAttr(node, src) {
44
+ const lt = findOpenAngle(node, src);
45
+ if (lt === null) return false;
46
+ const gt = src.indexOf(">", lt);
47
+ if (gt === -1) return false;
48
+ const openTag = src.slice(lt, gt);
49
+ return /\sdata-cms\s*=/.test(openTag);
50
+ }
51
+ function tagNameEnd(node, src) {
52
+ const lt = findOpenAngle(node, src);
53
+ if (lt === null) return null;
54
+ let i = lt + 1;
55
+ while (i < src.length && /[A-Za-z0-9\-:.]/.test(src[i])) i++;
56
+ return i;
57
+ }
58
+ function lineAt(src, offset) {
59
+ if (offset === void 0) return 0;
60
+ let line = 1;
61
+ for (let i = 0; i < offset && i < src.length; i++) {
62
+ if (src[i] === "\n") line++;
63
+ }
64
+ return line;
65
+ }
66
+ async function annotateSource(source, opts = {}) {
67
+ const tFn = opts.tFunction ?? "t";
68
+ const { ast } = await parse(source, { position: true });
69
+ const tagged = [];
70
+ const skipped = [];
71
+ const edits = [];
72
+ const claimed = /* @__PURE__ */ new Set();
73
+ function visit(node) {
74
+ if (isElement(node)) {
75
+ const kids = meaningfulChildren(node);
76
+ const onlyChild = kids.length === 1 ? kids[0] : null;
77
+ if (onlyChild && onlyChild.type === "expression") {
78
+ const exprSrc = expressionSource(onlyChild);
79
+ const key = matchTCall(exprSrc, tFn);
80
+ if (key !== null) {
81
+ claimed.add(onlyChild);
82
+ if (hasDataCmsAttr(node, source)) {
83
+ skipped.push({
84
+ key,
85
+ line: lineAt(source, node.position?.start?.offset),
86
+ reason: "already-annotated"
87
+ });
88
+ } else {
89
+ const at = tagNameEnd(node, source);
90
+ if (at !== null) {
91
+ edits.push({ at, text: ` data-cms="${key}"` });
92
+ tagged.push({
93
+ key,
94
+ tag: node.name,
95
+ line: lineAt(source, node.position?.start?.offset)
96
+ });
97
+ } else {
98
+ skipped.push({
99
+ key,
100
+ line: lineAt(source, node.position?.start?.offset),
101
+ reason: "unlocatable-tag"
102
+ });
103
+ }
104
+ }
105
+ } else if (isNonLiteralTCall(exprSrc, tFn)) {
106
+ claimed.add(onlyChild);
107
+ skipped.push({
108
+ key: null,
109
+ line: lineAt(source, onlyChild.position?.start?.offset),
110
+ reason: "non-literal-key"
111
+ });
112
+ }
113
+ } else if (kids.length > 1) {
114
+ for (const k of kids) {
115
+ if (k.type === "expression") {
116
+ const key = matchTCall(expressionSource(k), tFn);
117
+ if (key !== null) {
118
+ claimed.add(k);
119
+ skipped.push({
120
+ key,
121
+ line: lineAt(source, k.position?.start?.offset),
122
+ reason: "mixed-children"
123
+ });
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ for (const c of node.children ?? []) visit(c);
130
+ }
131
+ visit(ast);
132
+ function sweepAttributes(node) {
133
+ if (isElement(node)) {
134
+ for (const attr of node.attributes ?? []) {
135
+ for (const c of attr.children ?? []) {
136
+ if (c.type === "expression") {
137
+ const key = matchTCall(expressionSource(c), tFn);
138
+ if (key !== null && !claimed.has(c)) {
139
+ skipped.push({
140
+ key,
141
+ line: lineAt(source, c.position?.start?.offset),
142
+ reason: "attribute"
143
+ });
144
+ }
145
+ }
146
+ }
147
+ }
148
+ }
149
+ for (const c of node.children ?? []) sweepAttributes(c);
150
+ }
151
+ sweepAttributes(ast);
152
+ if (edits.length === 0) {
153
+ return { code: source, changed: false, tagged, skipped };
154
+ }
155
+ edits.sort((a, b) => b.at - a.at);
156
+ let code = source;
157
+ for (const e of edits) {
158
+ code = code.slice(0, e.at) + e.text + code.slice(e.at);
159
+ }
160
+ tagged.sort((a, b) => a.line - b.line);
161
+ skipped.sort((a, b) => a.line - b.line);
162
+ return { code, changed: true, tagged, skipped };
163
+ }
164
+
165
+ export {
166
+ annotateSource
167
+ };
@@ -0,0 +1 @@
1
+ export { AnnotateOptions, AnnotateResult, AnnotationSkip, AnnotationTag, annotateSource } from './annotate/index.js';
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ import {
2
+ annotateSource
3
+ } from "./chunk-4DOTVUP2.js";
4
+ export {
5
+ annotateSource
6
+ };
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/server.js ADDED
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ annotateSource
4
+ } from "./chunk-4DOTVUP2.js";
5
+
6
+ // src/server.ts
7
+ import { readFile, writeFile } from "fs/promises";
8
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
+ import { z } from "zod";
11
+
12
+ // src/paths.ts
13
+ import { resolve, relative, isAbsolute, extname } from "path";
14
+ var ALLOWED_EXTENSIONS = /* @__PURE__ */ new Set([".astro", ".tsx", ".jsx"]);
15
+ function resolveTargetPath(path, root = process.env.CANCIA_MCP_ROOT ?? process.cwd()) {
16
+ const absRoot = resolve(root);
17
+ const abs = resolve(absRoot, path);
18
+ const rel = relative(absRoot, abs);
19
+ if (rel.startsWith("..") || isAbsolute(rel)) {
20
+ return { ok: false, error: `Path escapes the project root (${absRoot}): ${path}` };
21
+ }
22
+ if (!ALLOWED_EXTENSIONS.has(extname(abs))) {
23
+ return { ok: false, error: `Unsupported file type "${extname(abs)}" \u2014 expected .astro, .tsx, or .jsx` };
24
+ }
25
+ return { ok: true, abs };
26
+ }
27
+
28
+ // src/server.ts
29
+ function renderReport(path, result, wrote) {
30
+ const lines = [];
31
+ lines.push(`File: ${path}`);
32
+ lines.push(
33
+ result.changed ? wrote ? `Status: ${result.tagged.length} element(s) annotated and written.` : `Status: ${result.tagged.length} element(s) would be annotated (dry run \u2014 nothing written).` : `Status: no changes \u2014 nothing to annotate.`
34
+ );
35
+ if (result.tagged.length) {
36
+ lines.push("");
37
+ lines.push("Annotated:");
38
+ for (const t of result.tagged) {
39
+ lines.push(` L${t.line} <${t.tag}> data-cms="${t.key}"`);
40
+ }
41
+ }
42
+ if (result.skipped.length) {
43
+ lines.push("");
44
+ lines.push("Skipped (review if any of these should be editable):");
45
+ for (const s of result.skipped) {
46
+ const label = s.reason === "attribute" ? `attribute t() \u2014 not a clickable text node` : s.reason === "mixed-children" ? `mixed children \u2014 element has other content besides the t() call` : s.reason === "already-annotated" ? `already has data-cms` : s.reason === "unlocatable-tag" ? `could not locate the opening tag \u2014 annotate manually` : `non-literal key \u2014 t(expr) not a string literal`;
47
+ lines.push(` L${s.line} ${s.key ? `"${s.key}" ` : ""}${label}`);
48
+ }
49
+ }
50
+ return lines.join("\n");
51
+ }
52
+ async function main() {
53
+ const server = new McpServer({
54
+ name: "cancia",
55
+ version: "0.0.1"
56
+ });
57
+ server.registerTool(
58
+ "annotate_page",
59
+ {
60
+ title: "Annotate page for CMS editing",
61
+ description: `Make an Astro/JSX component editable in the Cancia inline editor. Scans the file for t("KEY") calls rendered as the sole child of an element and inserts data-cms="KEY" on that element. Skips t() calls inside attributes (alt/title/aria) and flags elements with mixed children for manual review. Defaults to a dry run \u2014 pass write:true to save changes. The i18n keys themselves are assumed to already exist in the project's ui.ts (this tool only wires the markup, it does not create keys).`,
62
+ inputSchema: {
63
+ path: z.string().describe(
64
+ "Path to the .astro/.tsx/.jsx file, resolved against the project root (CANCIA_MCP_ROOT or the server's cwd). Paths may not escape the project root."
65
+ ),
66
+ write: z.boolean().optional().describe("Write changes to disk. Defaults to false (dry run)."),
67
+ tFunction: z.string().optional().describe('Translation function name to match. Defaults to "t".')
68
+ }
69
+ },
70
+ async ({ path, write, tFunction }) => {
71
+ const resolved = resolveTargetPath(path);
72
+ if (!resolved.ok) {
73
+ return {
74
+ isError: true,
75
+ content: [{ type: "text", text: resolved.error }]
76
+ };
77
+ }
78
+ const abs = resolved.abs;
79
+ let source;
80
+ try {
81
+ source = await readFile(abs, "utf8");
82
+ } catch (err) {
83
+ return {
84
+ isError: true,
85
+ content: [
86
+ {
87
+ type: "text",
88
+ text: `Could not read ${abs}: ${err instanceof Error ? err.message : String(err)}`
89
+ }
90
+ ]
91
+ };
92
+ }
93
+ const result = await annotateSource(source, { tFunction });
94
+ let wrote = false;
95
+ if (write && result.changed) {
96
+ await writeFile(abs, result.code, "utf8");
97
+ wrote = true;
98
+ }
99
+ return {
100
+ content: [{ type: "text", text: renderReport(abs, result, wrote) }]
101
+ };
102
+ }
103
+ );
104
+ const transport = new StdioServerTransport();
105
+ await server.connect(transport);
106
+ }
107
+ main().catch((err) => {
108
+ console.error("cancia-mcp fatal:", err);
109
+ process.exit(1);
110
+ });
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@cancia/mcp",
3
+ "version": "0.0.1",
4
+ "description": "MCP server for Cancia — agent tools for annotating Astro sites and editing content",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/ninuzdellalb/cancia",
9
+ "directory": "packages/mcp"
10
+ },
11
+ "type": "module",
12
+ "bin": {
13
+ "cancia-mcp": "./dist/server.js"
14
+ },
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ },
22
+ "./annotate": {
23
+ "types": "./dist/annotate/index.d.ts",
24
+ "import": "./dist/annotate/index.js"
25
+ }
26
+ },
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "dependencies": {
31
+ "@astrojs/compiler": "^2.10.0",
32
+ "@modelcontextprotocol/sdk": "^1.0.0",
33
+ "zod": "^4.4.3"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^25.9.1",
37
+ "tsup": "^8.0.0",
38
+ "tsx": "^4.0.0",
39
+ "typescript": "^5.4.0",
40
+ "vitest": "^4.1.9"
41
+ },
42
+ "scripts": {
43
+ "dev": "tsx watch src/server.ts",
44
+ "build": "tsup",
45
+ "typecheck": "tsc --noEmit",
46
+ "test": "vitest run"
47
+ }
48
+ }