Skip to content

Commit 9e6d937

Browse files
authored
Merge pull request #57 from ChanMeng666/release/v1.24.0
release: v1.24.0 — curved geometry (Batch-2 finale) + mcp 0.2.3
2 parents 7b10c29 + 4c634ab commit 9e6d937

9 files changed

Lines changed: 185 additions & 40 deletions

File tree

AGENTS.md

Lines changed: 28 additions & 20 deletions
Large diffs are not rendered by default.

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.24.0] - 2026-07-26
11+
1012
### Added
1113

1214
- **`arc (x,y) radius R [cw|ccw] [major]` — curved wall edges.** Written where a vertex goes
@@ -70,6 +72,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7072
legitimately reports no **`adjacent`** rooms (a circle meets a straight wall at a point, and
7173
`adjacent` has always meant a shared *run*) and carries its connectivity through its doors.
7274

75+
### MCP shim (`@chanmeng666/archlang-mcp` 0.2.3)
76+
77+
- **Its shipped context resources were five releases stale.** `archlang://spec`,
78+
`archlang://context` and `archlang://grammar` are copied into the package at pack time, so the
79+
published 0.2.2 described the **v1.19** language while its dependency range resolved to a current
80+
core — an MCP-native agent was told nothing about `paper`/`scale`, `level`, `place`, `zone`,
81+
`room polygon` or `arc`, and the GBNF grammar it was handed could not *decode* them at all. This
82+
republish refreshes all five resources; the dependency range is now `^1.24.0`, so the range can
83+
no longer promise a surface the installed core lacks.
84+
- **`compile` no longer answers a multi-storey plan with the ground floor alone.** It returns every
85+
storey in `pages[]` (`{ level, name, output }`) and takes a `level` selector; `output` still holds
86+
the lowest storey, so a level-unaware caller is unchanged, and a single-storey result carries no
87+
`pages` key. An undeclared `level` comes back as an error naming the real `levels` — never a
88+
silent substitution that would read as a successful render of the storey you asked for.
89+
- **The handshake reports the shim's real version.** It was a hardcoded `"0.2.0"` from 0.2.1 on, so
90+
the server misintroduced itself to every host; it is now derived from `package.json` the way the
91+
core CLI derives its own, and a test pins the two together.
92+
- Tool descriptions now name the current surface (the shape facts `floor_polygon` / `floor_circle`,
93+
`zones`, `levels`, `vertical`, `bbox_outer`, `freedom`) instead of the v1.14 one.
94+
7395
### Diagnostics
7496

7597
- **`E_ARC_RADIUS`** — a radius under half the chord describes no circle through the two

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@chanmeng666/archlang",
3-
"version": "1.23.0",
3+
"version": "1.24.0",
44
"description": "A small declarative language that compiles to professional SVG floor plans — like Typst/LaTeX, but for architecture.",
55
"keywords": [
66
"architecture",

packages/mcp/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,11 @@ structured JSON. `compile` also accepts `plan_json` (the Plan-JSON / RPLAN shape
3333

3434
| Tool | What it does |
3535
|------|--------------|
36-
| `compile` | Render source (or `plan_json`) to SVG, or to zero-dep ASCII (`format:"txt"`). `accessible`, `overlay:"circulation"` opt-ins. Returns output + diagnostics. |
37-
| `describe` | Semantic facts (rooms, areas, adjacency, doors, circulation, and a `freedom` report of which positions are hand-authored vs resolver-derived) — verify intent without an image. |
36+
| `compile` | Render source (or `plan_json`) to SVG, or to zero-dep ASCII (`format:"txt"`). `accessible`, `overlay:"circulation"` opt-ins. Returns output + diagnostics. A multi-storey plan returns every storey in `pages[]`; `level` renders one. |
37+
| `describe` | Semantic facts (rooms, areas, adjacency, doors, circulation, zones, storeys, the exact `floor_polygon`/`floor_circle` shape, and a `freedom` report of which positions are hand-authored vs resolver-derived) — verify intent without an image. |
3838
| `lint` | Advisory `W_*` soundness warnings (may include the fix-carrying `W_ALIAS_MATCH` for a room use inferred from an indirect label alias); `profile` selects a ruleset. |
39-
| `validate` | Parse + resolve + lint, the ship gate. `strict` fails on warnings; `graph` checks interior-door adjacency against an intended room graph. |
39+
| `validate` | Parse + resolve + lint, the ship gate. `strict` fails on warnings; `graph` checks interior-door adjacency against an intended room graph; `intent` gates on a brief's contract (per `archlang://intent-schema`). |
40+
| `score` | The continuous intent METER: how much of a brief a plan satisfies (`satisfied`/`total`/`score`/`subscores`). Measures, never gates. |
4041
| `repair` | Explicit corrector: furniture out of walls/doorways/swings → new `.arch` source + change log. Never adds doors/windows. |
4142
| `fix` | Apply the machine-applicable fixes on a plan's diagnostics (syntactic corrector; bounded fixpoint). `unsafe` widens to `maybe-incorrect`. |
4243
| `suggest` | Advisory door/window statements (each referencing its wall by a stable ref — an authored id or a unique category — or absolute coordinates, never a re-bindable positional id) to resolve unreachable rooms / windowless bedrooms — data, never applied. |
@@ -50,6 +51,7 @@ structured JSON. `compile` also accepts `plan_json` (the Plan-JSON / RPLAN shape
5051
| `archlang://context` | Full agent context: spec + workflow + CLI reference + error catalog (`llms-full.txt`). |
5152
| `archlang://schema` | JSON Schema (2020-12) for the Plan-JSON compile input. |
5253
| `archlang://grammar` | GBNF constrained-decoding grammar for guaranteed-parseable generation. |
54+
| `archlang://intent-schema` | JSON Schema (2020-12) for a brief's Intent — the shape `validate`'s `intent` and `score`'s `brief` take. |
5355

5456
## Install
5557

packages/mcp/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"name": "@chanmeng666/archlang-mcp",
3-
"version": "0.2.2",
3+
"version": "0.2.3",
44
"mcpName": "io.github.ChanMeng666/archlang-mcp",
5-
"description": "Model Context Protocol (MCP) server for ArchLang — compile, describe, lint, validate, score, repair, fix and suggest floor plans over stdio, plus the v1.14 intent channel (gate + continuous score against a brief). A thin shim over @chanmeng666/archlang; the CLI stays the primary, token-cheaper interface.",
5+
"description": "Model Context Protocol (MCP) server for ArchLang — compile, describe, lint, validate, score, repair, fix and suggest floor plans over stdio, plus the intent channel (gate + continuous score against a brief). Covers the whole current language: multi-storey `level` plans with stairs, `place`d components, zones, scaled paper with dimension chains and room schedules, and non-rectilinear `room polygon` / `room circle` / `arc` walls. A thin shim over @chanmeng666/archlang; the CLI stays the primary, token-cheaper interface.",
66
"keywords": [
77
"mcp",
88
"model-context-protocol",
@@ -42,7 +42,7 @@
4242
"prepack": "npm run build"
4343
},
4444
"dependencies": {
45-
"@chanmeng666/archlang": "^1.14.0",
45+
"@chanmeng666/archlang": "^1.24.0",
4646
"@modelcontextprotocol/sdk": "^1.29.0",
4747
"zod": "^3.25.76"
4848
},

packages/mcp/server.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
33
"name": "io.github.ChanMeng666/archlang-mcp",
4-
"description": "Compile, describe, lint, validate, score and repair ArchLang floor plans (.arch to SVG) over stdio.",
5-
"version": "0.2.2",
4+
"description": "Compile, describe, lint, validate, score and repair multi-storey and curved ArchLang floor plans.",
5+
"version": "0.2.3",
66
"repository": {
77
"url": "https://github.com/ChanMeng666/archlang",
88
"source": "github",
@@ -14,7 +14,7 @@
1414
"registryType": "npm",
1515
"registryBaseUrl": "https://registry.npmjs.org",
1616
"identifier": "@chanmeng666/archlang-mcp",
17-
"version": "0.2.2",
17+
"version": "0.2.3",
1818
"runtimeHint": "npx",
1919
"transport": {
2020
"type": "stdio"

packages/mcp/src/server.ts

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,29 @@ function readResource(flat: string, repoRel: string): string {
4848
return `(${flat} not found — run \`npm run mcp:build\`)`;
4949
}
5050

51+
/**
52+
* This shim's own version, for the MCP handshake — **derived, never retyped**. It was a
53+
* hardcoded literal through 0.2.1–0.2.2, so the published server introduced itself as
54+
* "0.2.0" and a host had no reliable way to tell which shim it was talking to. Mirrors the
55+
* core's `readVersion()` (`src/cli/io.ts`): `../package.json` resolves from both `dist/`
56+
* (the packed artifact — npm always includes package.json) and `src/` (the tests).
57+
*/
58+
function readShimVersion(): string {
59+
for (const rel of ["../package.json", "../../package.json"]) {
60+
const p = resolve(HERE, rel);
61+
if (existsSync(p)) {
62+
try {
63+
const v = JSON.parse(readFileSync(p, "utf8")).version;
64+
if (typeof v === "string") return v;
65+
} catch {
66+
/* fall through to the next candidate */
67+
}
68+
}
69+
}
70+
return "0.0.0";
71+
}
72+
export const SHIM_VERSION = readShimVersion();
73+
5174
// ---------------------------------------------------------------------------
5275
// helpers
5376
// ---------------------------------------------------------------------------
@@ -103,14 +126,14 @@ function resolveSource(input: {
103126

104127
/** Build the fully-configured ArchLang MCP server (tools + resources). */
105128
export function createServer(): McpServer {
106-
const server = new McpServer({ name: "archlang", version: "0.2.0" });
129+
const server = new McpServer({ name: "archlang", version: SHIM_VERSION });
107130

108131
server.registerTool(
109132
"compile",
110133
{
111134
title: "Compile ArchLang → SVG or ASCII",
112135
description:
113-
'Compile ArchLang `.arch` source (or a Plan-JSON object) to an SVG floor plan, or to a zero-dependency ASCII text plan (format:"txt"). Returns the rendered output plus diagnostics — each a byte span, line/col, catalogued E_/W_ code, and a machine-applicable fix. Errors are DATA, never exceptions: read `diagnostics` and correct the source.',
136+
'Compile ArchLang `.arch` source (or a Plan-JSON object) to an SVG floor plan, or to a zero-dependency ASCII text plan (format:"txt"). Returns the rendered output plus diagnostics — each a byte span, line/col, catalogued E_/W_ code, and a machine-applicable fix. Errors are DATA, never exceptions: read `diagnostics` and correct the source. A MULTI-STOREY plan (`level <n> { … }`) is a set of drawings: `output` is the lowest storey and every storey comes back in `pages[]` ({ level, name, output }), so pass `level` to render just one.',
114137
inputSchema: {
115138
source: z.string().optional().describe('ArchLang source (a `plan "…" { … }`). Provide this OR plan_json.'),
116139
plan_json: z
@@ -120,21 +143,59 @@ export function createServer(): McpServer {
120143
format: z.enum(["svg", "txt"]).optional().describe("svg (default) or txt (zero-dependency ASCII)."),
121144
accessible: z.boolean().optional().describe("Emit <title>/<desc>/role/aria accessibility metadata (SVG only)."),
122145
overlay: z.enum(["circulation"]).optional().describe("Draw an opt-in circulation overlay (SVG only)."),
146+
level: z
147+
.number()
148+
.int()
149+
.optional()
150+
.describe("Render only this storey of a multi-storey plan. An undeclared level is an error, not a guess."),
123151
},
124152
},
125153
async (a) => {
126154
const r = resolveSource(a);
127155
if ("diagnostics" in r) return json({ ok: false, diagnostics: r.diagnostics });
128156
const format = a.format ?? "svg";
129-
const { svg, diagnostics, scene } = compile(r.source, {
157+
const { svg, diagnostics, scene, pages } = compile(r.source, {
130158
noCache: true,
131159
...(a.accessible ? { accessible: true } : {}),
132160
...(a.overlay === "circulation" ? { overlays: ["circulation"] as const } : {}),
133161
...(format === "txt" ? { annotate: true } : {}),
134162
});
135163
const diags = toJson(r.source, diagnostics);
136164
if (errorCount(diagnostics) > 0 || !scene) return json({ ok: false, format, diagnostics: diags });
137-
return json({ ok: true, format, output: format === "txt" ? renderAscii(scene) : svg, diagnostics: diags });
165+
const render = (pageSvg: string, pageScene: typeof scene) =>
166+
format === "txt" ? renderAscii(pageScene) : pageSvg;
167+
// A `level` this plan does not declare is refused with the real set — never silently
168+
// answered with the ground floor, which would look like a successful render of it.
169+
if (a.level !== undefined) {
170+
const levels = pages?.map((p) => p.level) ?? [];
171+
const want = pages?.find((p) => p.level === a.level);
172+
if (!want)
173+
return json({ ok: false, format, error: `no level ${a.level} in this plan`, levels, diagnostics: diags });
174+
return json({
175+
ok: true,
176+
format,
177+
level: want.level,
178+
output: render(want.svg, want.scene),
179+
levels,
180+
diagnostics: diags,
181+
});
182+
}
183+
return json({
184+
ok: true,
185+
format,
186+
output: render(svg, scene),
187+
// Present only for a genuinely multi-storey plan, so a single-storey result is unchanged.
188+
...(pages
189+
? {
190+
pages: pages.map((p) => ({
191+
level: p.level,
192+
...(p.name !== undefined ? { name: p.name } : {}),
193+
output: render(p.svg, p.scene),
194+
})),
195+
}
196+
: {}),
197+
diagnostics: diags,
198+
});
138199
},
139200
);
140201

@@ -143,7 +204,7 @@ export function createServer(): McpServer {
143204
{
144205
title: "Describe a plan (facts, no render)",
145206
description:
146-
"Semantic facts about a plan without rendering: rooms (areas, bboxes, adjacency, uses), doors (what they connect), windows, circulation (walk distance / bottleneck width / detour), and totals. The channel a text-only agent uses to VERIFY that a plan matches intent.",
207+
"Semantic facts about a plan without rendering: rooms (areas, bboxes, adjacency, uses, and the exact shape — `floor_polygon` for a `room polygon`, `floor_circle` for a `room circle`, whose area is exact πR²), doors (what they connect), windows, circulation (walk distance / bottleneck width / detour), zones, storeys (`levels[i]` carries the same facts per floor) with the shafts that join them and which storeys stay reachable (`vertical`), the drawing extent (`bbox`, `bbox_outer`), `freedom` (which positions are hand-authored vs resolver-derived), and totals. The channel a text-only agent uses to VERIFY that a plan matches intent.",
147208
inputSchema: { source: z.string().describe("ArchLang source.") },
148209
},
149210
async ({ source }) => {

packages/mcp/test/server.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
22
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
33
import { describe, expect, it } from "vitest";
4-
import { createServer } from "../src/server.js";
4+
import pkg from "../package.json" with { type: "json" };
5+
import { createServer, SHIM_VERSION } from "../src/server.js";
56

67
/** Link a fresh server to a client over the SDK's in-process transport. */
78
async function connect(): Promise<Client> {
@@ -21,6 +22,17 @@ function payload(result: unknown): Record<string, unknown> {
2122

2223
const TINY = 'plan "Smoke" {\n room at (0,0) size 4000x3000 label "Room"\n}\n';
2324

25+
/** Two storeys — the shape that used to come back as the ground floor alone. */
26+
const TWO_STOREY = `plan "Stack" {
27+
level 1 "Ground floor" {
28+
room at (0,0) size 4000x3000 label "Living"
29+
}
30+
level 2 "First floor" {
31+
room at (0,0) size 4000x3000 label "Bedroom"
32+
}
33+
}
34+
`;
35+
2436
describe("archlang mcp server", () => {
2537
it("exposes the wrapping tools and resources", async () => {
2638
const client = await connect();
@@ -57,6 +69,46 @@ describe("archlang mcp server", () => {
5769
expect((out.diagnostics as unknown[]).length).toBeGreaterThan(0);
5870
});
5971

72+
it("introduces itself with its REAL version, not a hardcoded literal", async () => {
73+
const client = await connect();
74+
// The handshake version was frozen at "0.2.0" through 0.2.1–0.2.2; deriving it from
75+
// package.json is what keeps it honest, so pin it to the package rather than to a string.
76+
expect(SHIM_VERSION).toBe(pkg.version);
77+
expect(client.getServerVersion()?.version).toBe(pkg.version);
78+
});
79+
80+
it("compile returns every storey of a multi-storey plan, not just the ground floor", async () => {
81+
const client = await connect();
82+
const out = payload(await client.callTool({ name: "compile", arguments: { source: TWO_STOREY } }));
83+
expect(out.ok).toBe(true);
84+
const pages = out.pages as Array<{ level: number; name?: string; output: string }>;
85+
expect(pages.map((p) => p.level)).toEqual([1, 2]);
86+
expect(pages.map((p) => p.name)).toEqual(["Ground floor", "First floor"]);
87+
// `output` stays the lowest storey, so a level-unaware caller is unaffected.
88+
expect(out.output).toBe(pages[0]?.output);
89+
// Each storey is its own drawing: the upper floor is not a copy of the ground floor.
90+
expect(pages[1]?.output).not.toBe(pages[0]?.output);
91+
});
92+
93+
it("compile --level renders one storey, and refuses one the plan lacks", async () => {
94+
const client = await connect();
95+
const one = payload(await client.callTool({ name: "compile", arguments: { source: TWO_STOREY, level: 2 } }));
96+
expect(one.ok).toBe(true);
97+
expect(one.level).toBe(2);
98+
expect(one.pages).toBeUndefined();
99+
const bad = payload(await client.callTool({ name: "compile", arguments: { source: TWO_STOREY, level: 7 } }));
100+
expect(bad.ok).toBe(false);
101+
expect(bad.levels).toEqual([1, 2]);
102+
expect(bad.output).toBeUndefined();
103+
});
104+
105+
it("a single-storey compile carries no pages key", async () => {
106+
const client = await connect();
107+
const out = payload(await client.callTool({ name: "compile", arguments: { source: TINY } }));
108+
expect(out.ok).toBe(true);
109+
expect(out.pages).toBeUndefined();
110+
});
111+
60112
it("describe returns rooms and totals", async () => {
61113
const client = await connect();
62114
const out = payload(await client.callTool({ name: "describe", arguments: { source: TINY } }));

0 commit comments

Comments
 (0)