leglas-mcp 0.2.0 → 0.3.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 +71 -19
- package/dist/bin.js +11 -4
- package/dist/channel.d.ts +2 -1
- package/dist/channel.js +10 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/project.d.ts +78 -0
- package/dist/project.js +102 -0
- package/dist/tools.d.ts +2 -1
- package/dist/tools.js +31 -12
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ and sessions that clean up after themselves.
|
|
|
52
52
|
- Ask for changes without leaving the comparison: describe what you
|
|
53
53
|
want on the direction you're looking at, and Leglas turns it into a
|
|
54
54
|
precise request for your agent, file path included. Leave
|
|
55
|
-
`leglas watch --run "claude -p {prompt}"` running in another terminal
|
|
55
|
+
`npx leglas watch --run "claude -p {prompt}"` running in another terminal
|
|
56
56
|
and your agent picks each request up as you send it. In Claude Code,
|
|
57
57
|
the Leglas MCP server can also push each request straight into your
|
|
58
58
|
open session as a channel event (channels are a research preview:
|
|
@@ -76,57 +76,81 @@ preview of your app root. Add a config file to compare more than one thing.
|
|
|
76
76
|
|
|
77
77
|
It works with whatever you're building in. Leglas never imports or
|
|
78
78
|
executes your framework, so the target can be Next, Vite, Remix,
|
|
79
|
-
SvelteKit, Astro, or a folder of static files.
|
|
80
|
-
|
|
79
|
+
SvelteKit, Astro, or a folder of static files.
|
|
80
|
+
|
|
81
|
+
## Install
|
|
82
|
+
|
|
83
|
+
There is nothing you have to install: `npx leglas` fetches the CLI on
|
|
84
|
+
first use and starts from npm's cache after that, and every instruction
|
|
85
|
+
Leglas writes for agents uses the same form, so a fresh clone works
|
|
86
|
+
with no setup at all. Requires Node 24 or newer.
|
|
87
|
+
|
|
88
|
+
Two optional upgrades:
|
|
89
|
+
|
|
90
|
+
- `npm install -D leglas` pins the version in a project. Teammates and
|
|
91
|
+
CI get the same Leglas from their normal install, and `npx` resolves
|
|
92
|
+
the local copy from then on.
|
|
93
|
+
- `npm install -g leglas` is for typing `leglas` without the prefix.
|
|
81
94
|
|
|
82
95
|
## Working with coding agents
|
|
83
96
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
97
|
+
The fastest way in is the agent skill:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
npx skills add FredAmartey/leglas
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
One install, and your agent recognises "give me a few directions for the
|
|
104
|
+
pricing page" as a Leglas exploration in any project, including ones
|
|
105
|
+
that have never seen Leglas. It sets the project up itself and gets to
|
|
106
|
+
work.
|
|
107
|
+
|
|
108
|
+
In a project, run `npx leglas init` once. It writes a section into your
|
|
109
|
+
project's `AGENTS.md`, creates a starter config, and gitignores Leglas's
|
|
110
|
+
working directory. That section travels with the repo, so Claude Code,
|
|
111
|
+
Cursor, Codex, or whatever you switch to next opens the project already
|
|
112
|
+
knowing how to add design directions to it. Every command accepts
|
|
113
|
+
`--json` and prints a single machine-readable envelope, so agents drive
|
|
114
|
+
the same CLI you do.
|
|
91
115
|
|
|
92
116
|
The instructions center on one rule: add beside what exists, never
|
|
93
117
|
rewrite it. Two directions that rewrite the same file cannot render from
|
|
94
118
|
one server, and asking an agent to "make the hero calmer" tempts it to
|
|
95
119
|
edit the hero. The supporting commands:
|
|
96
120
|
|
|
97
|
-
- `leglas explore hero --count 6` briefs the exploration: what the set
|
|
121
|
+
- `npx leglas explore hero --count 6` briefs the exploration: what the set
|
|
98
122
|
is for, why it only works if the six genuinely disagree, and how each
|
|
99
123
|
direction registers. Unbriefed, six requests come back as six variants
|
|
100
124
|
of one idea. With `--based-on "Aurora"` the goal flips: six deliberate
|
|
101
125
|
variants of a direction you already like, and drifting into a new
|
|
102
126
|
direction is the failure. The designs themselves are the agent's;
|
|
103
127
|
Leglas prescribes none.
|
|
104
|
-
- `leglas new hero --from src/Hero.tsx` scaffolds a switcher under
|
|
128
|
+
- `npx leglas new hero --from src/Hero.tsx` scaffolds a switcher under
|
|
105
129
|
`.leglas/variants/hero/`. With `--from`, the baseline re-exports your
|
|
106
130
|
real component, so you never compare against a stale copy. Leglas
|
|
107
131
|
prints the one line to add in your component and does not edit it,
|
|
108
132
|
because rewriting a file it does not understand is how a tool breaks a
|
|
109
133
|
codebase. Scaffolded branch points return the fallback in production
|
|
110
134
|
builds, so a committed one cannot expose an unreleased direction.
|
|
111
|
-
- `leglas classify --change package.json --rewrite src/theme.css` answers
|
|
135
|
+
- `npx leglas classify --change package.json --rewrite src/theme.css` answers
|
|
112
136
|
where a direction should live before it is written. Changing
|
|
113
137
|
dependencies, build configuration, or an existing file's behaviour
|
|
114
138
|
cannot be additive, so those directions build on their own git branch
|
|
115
139
|
and register with `leglas add --branch`. Everything else stays in-app,
|
|
116
140
|
where switching is instant.
|
|
117
|
-
- `leglas show "Aurora" --json` answers for one direction: its entry, the
|
|
141
|
+
- `npx leglas show "Aurora" --json` answers for one direction: its entry, the
|
|
118
142
|
source file behind it, the variants based on it, what it is being compared
|
|
119
143
|
against, and anything still pending on it. Copying a direction from the
|
|
120
144
|
rail hands over a block that ends in this command, so an agent given the
|
|
121
145
|
block can go and get the rest.
|
|
122
|
-
- `leglas keep "Aurora" --to src/components/hero.tsx` moves the winner
|
|
146
|
+
- `npx leglas keep "Aurora" --to src/components/hero.tsx` moves the winner
|
|
123
147
|
into real source and ends the exploration.
|
|
124
148
|
|
|
125
149
|
Asking for a change works from the interface too. Type what you want
|
|
126
150
|
changed into the field under the rail (or press `R`) and Leglas composes a
|
|
127
151
|
prompt naming the direction and the file behind it, copies it to your
|
|
128
152
|
clipboard, and queues it. The direction it means is the one highlighted
|
|
129
|
-
directly above the field. Your agent drains the queue with `leglas requests --json` and clears
|
|
153
|
+
directly above the field. Your agent drains the queue with `npx leglas requests --json` and clears
|
|
130
154
|
it with `--clear`. Leglas runs no model of its own; your agent already
|
|
131
155
|
knows your conventions and your taste.
|
|
132
156
|
|
|
@@ -136,6 +160,9 @@ For agent hosts that cannot run shell commands, `leglas-mcp` exposes the
|
|
|
136
160
|
same operations as MCP tools over stdio: `start`, `add`, `list`, `show`,
|
|
137
161
|
`classify`, `explore`, `scaffold`, `keep`, `requests`, and `init`. Each
|
|
138
162
|
tool calls exactly what the CLI calls and returns the same envelope.
|
|
163
|
+
`watch` is the one command with no tool behind it: it is a loop that
|
|
164
|
+
holds a terminal open, and on a host that speaks channels the server
|
|
165
|
+
already pushes each request into the session as it arrives.
|
|
139
166
|
|
|
140
167
|
```sh
|
|
141
168
|
claude mcp add leglas -- npx -y leglas-mcp
|
|
@@ -147,9 +174,34 @@ Or in `.mcp.json`:
|
|
|
147
174
|
{ "mcpServers": { "leglas": { "command": "npx", "args": ["-y", "leglas-mcp"] } } }
|
|
148
175
|
```
|
|
149
176
|
|
|
150
|
-
The host's working directory names the project
|
|
151
|
-
|
|
152
|
-
|
|
177
|
+
The host's working directory names the project, the same contract as the
|
|
178
|
+
CLI. A host that starts the server somewhere else is asked where the
|
|
179
|
+
project is, over MCP roots. The `start` tool boots the viewer and returns
|
|
180
|
+
its URL, and anything it started stops when the session ends.
|
|
181
|
+
|
|
182
|
+
### As an Agent Plugin
|
|
183
|
+
|
|
184
|
+
The repository is also an [Agent Plugin](https://agent-plugins.org), the
|
|
185
|
+
open standard for shipping Agent Skills and MCP server configuration in
|
|
186
|
+
one format. Clients that implement it install the skill and the server
|
|
187
|
+
together, instead of the two steps above. It is a layout rather than a
|
|
188
|
+
build: `plugin.json` and `mcp.json` at the root, the skill in
|
|
189
|
+
`skills/leglas/`, nothing generated.
|
|
190
|
+
|
|
191
|
+
An Agent Plugins client starts a plugin's server in the plugin's own
|
|
192
|
+
install directory rather than the project, so on that path the working
|
|
193
|
+
directory names a copy of Leglas and nothing else. The server therefore
|
|
194
|
+
takes the project from the workspace the host declares over MCP roots,
|
|
195
|
+
and the working directory only when it sits inside one. If a host offers
|
|
196
|
+
neither, `LEGLAS_PROJECT_DIR` names the project outright; without it the
|
|
197
|
+
tools report that there is no project rather than writing into a plugin
|
|
198
|
+
cache. `mcp.json` passes `${PLUGIN_ROOT}` for exactly that check, and
|
|
199
|
+
nothing else.
|
|
200
|
+
|
|
201
|
+
The plugin's version covers the skill and the configuration, not the
|
|
202
|
+
server it launches: `npx` fetches the current `leglas-mcp` the same way
|
|
203
|
+
every `npx leglas` in these instructions fetches the current CLI, which
|
|
204
|
+
keeps both faces of Leglas on one version in a project they share.
|
|
153
205
|
|
|
154
206
|
## Configuration
|
|
155
207
|
|
package/dist/bin.js
CHANGED
|
@@ -3,10 +3,12 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { CHANNEL_CAPABILITY, CHANNEL_INSTRUCTIONS, startChannel } from "./channel.js";
|
|
6
|
+
import { hostProject } from "./project.js";
|
|
6
7
|
import { registerLeglasTools } from "./tools.js";
|
|
7
8
|
/**
|
|
8
|
-
* The stdio face.
|
|
9
|
-
*
|
|
9
|
+
* The stdio face. A host that spawns this in the project directory gets the
|
|
10
|
+
* CLI's contract, where the working directory names the project; a host that
|
|
11
|
+
* spawns it somewhere else is asked where the project is. See project.ts.
|
|
10
12
|
*/
|
|
11
13
|
function version() {
|
|
12
14
|
const require = createRequire(import.meta.url);
|
|
@@ -18,7 +20,12 @@ const server = new McpServer({ name: "leglas", version: version() },
|
|
|
18
20
|
// speak channels; on Claude Code they let change requests from the
|
|
19
21
|
// interface arrive in the open session as events.
|
|
20
22
|
{ capabilities: { experimental: CHANNEL_CAPABILITY }, instructions: CHANNEL_INSTRUCTIONS });
|
|
21
|
-
const
|
|
23
|
+
const project = hostProject(server.server, {
|
|
24
|
+
cwd: process.cwd(),
|
|
25
|
+
override: process.env["LEGLAS_PROJECT_DIR"],
|
|
26
|
+
pluginRoot: process.env["LEGLAS_PLUGIN_ROOT"],
|
|
27
|
+
});
|
|
28
|
+
const tools = registerLeglasTools(server, { project });
|
|
22
29
|
let channel = null;
|
|
23
30
|
let stopping = false;
|
|
24
31
|
const shutdown = async () => {
|
|
@@ -39,4 +46,4 @@ transport.onclose = () => {
|
|
|
39
46
|
};
|
|
40
47
|
await server.connect(transport);
|
|
41
48
|
// Only after connect: a notification with no transport throws.
|
|
42
|
-
channel = startChannel(server, {
|
|
49
|
+
channel = startChannel(server, { project });
|
package/dist/channel.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { type PendingRequest } from "leglas";
|
|
3
|
+
import type { Project } from "./project.js";
|
|
3
4
|
/**
|
|
4
5
|
* The push face, for hosts that treat an MCP server as a channel.
|
|
5
6
|
*
|
|
@@ -51,7 +52,7 @@ export type Channel = {
|
|
|
51
52
|
* because the queue file still holds the request.
|
|
52
53
|
*/
|
|
53
54
|
export declare function startChannel(server: McpServer, options: {
|
|
54
|
-
|
|
55
|
+
project: Project;
|
|
55
56
|
pollMs?: number;
|
|
56
57
|
/** Tests gate this to force overlapping polls; production reads the file. */
|
|
57
58
|
read?: (cwd: string) => Promise<PendingRequest[]>;
|
package/dist/channel.js
CHANGED
|
@@ -70,7 +70,16 @@ export function startChannel(server, options) {
|
|
|
70
70
|
}
|
|
71
71
|
};
|
|
72
72
|
const push = async () => {
|
|
73
|
-
|
|
73
|
+
// The first poll waits here for the host to initialize, which is what
|
|
74
|
+
// makes the queue this reads the project's rather than whatever directory
|
|
75
|
+
// the process happened to start in.
|
|
76
|
+
const located = await options.project.locate();
|
|
77
|
+
if (!located.ok) {
|
|
78
|
+
// Settled for the life of the process, so there is no queue coming.
|
|
79
|
+
clearInterval(timer);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const fresh = unpushed(await read(located.directory), pushed);
|
|
74
83
|
for (const request of fresh) {
|
|
75
84
|
pushed.add(request.id);
|
|
76
85
|
try {
|
package/dist/index.d.ts
CHANGED
|
@@ -2,3 +2,5 @@ export { registerLeglasTools } from "./tools.js";
|
|
|
2
2
|
export type { LeglasTools } from "./tools.js";
|
|
3
3
|
export { CHANNEL_CAPABILITY, CHANNEL_INSTRUCTIONS, CHANNEL_POLL_MS, channelEvent, startChannel, unpushed, } from "./channel.js";
|
|
4
4
|
export type { Channel, ChannelEvent } from "./channel.js";
|
|
5
|
+
export { UNRESOLVED_PROJECT, fixedProject, hostProject } from "./project.js";
|
|
6
|
+
export type { HostProjectOptions, Located, Project, RootsHost } from "./project.js";
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which project the tools act on.
|
|
3
|
+
*
|
|
4
|
+
* Every other face of Leglas learns this the same easy way: the CLI runs in
|
|
5
|
+
* the project, so the working directory is the answer. An MCP server is not
|
|
6
|
+
* always so lucky. An Agent Plugins client starts a plugin's server in the
|
|
7
|
+
* plugin's own install directory (Agent Plugins 1.0.0 §7.2.1), which holds a
|
|
8
|
+
* copy of Leglas and never a project anyone is designing. Taken at face value
|
|
9
|
+
* there, the working directory would send `init` into a plugin cache, leave
|
|
10
|
+
* the rail empty, and report all of it as success.
|
|
11
|
+
*
|
|
12
|
+
* So the directory is asked for rather than assumed:
|
|
13
|
+
*
|
|
14
|
+
* 1. `LEGLAS_PROJECT_DIR`, when someone has said outright where to work.
|
|
15
|
+
* 2. The workspace the host declares over MCP roots, which is the project it
|
|
16
|
+
* has open. The working directory wins when it sits inside one of those
|
|
17
|
+
* roots, because a host started in `packages/app` means that, not the
|
|
18
|
+
* repository above it. Several roots and no match takes the first, which
|
|
19
|
+
* is a guess, and the reason the override above exists.
|
|
20
|
+
* 3. The working directory, which is the right answer for every host that
|
|
21
|
+
* starts the server in the project, and the reason nothing changes for
|
|
22
|
+
* `claude mcp add` or a hand-written `.mcp.json`.
|
|
23
|
+
*
|
|
24
|
+
* When none of those name a project, the tools say so. Refusing is the whole
|
|
25
|
+
* point: a wrong directory is worse than no directory, because it writes.
|
|
26
|
+
*/
|
|
27
|
+
export type Located = {
|
|
28
|
+
ok: true;
|
|
29
|
+
directory: string;
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
reason: string;
|
|
33
|
+
};
|
|
34
|
+
export type Project = {
|
|
35
|
+
/**
|
|
36
|
+
* The project directory, resolved once and held for the life of the process.
|
|
37
|
+
* A viewer, a request queue, and a channel all bind to one project, so a
|
|
38
|
+
* session that changed its mind halfway would already have a server running
|
|
39
|
+
* against the old one.
|
|
40
|
+
*/
|
|
41
|
+
locate(): Promise<Located>;
|
|
42
|
+
};
|
|
43
|
+
/** A project fixed to one directory: what an embedder or a test already knows. */
|
|
44
|
+
export declare function fixedProject(directory: string): Project;
|
|
45
|
+
/**
|
|
46
|
+
* The part of an MCP server this needs. The SDK's `Server` satisfies it
|
|
47
|
+
* structurally, which keeps the resolution testable without a transport.
|
|
48
|
+
*/
|
|
49
|
+
export type RootsHost = {
|
|
50
|
+
getClientCapabilities(): {
|
|
51
|
+
roots?: unknown;
|
|
52
|
+
} | undefined;
|
|
53
|
+
listRoots(): Promise<{
|
|
54
|
+
roots: {
|
|
55
|
+
uri: string;
|
|
56
|
+
}[];
|
|
57
|
+
}>;
|
|
58
|
+
oninitialized?: (() => void) | undefined;
|
|
59
|
+
};
|
|
60
|
+
export type HostProjectOptions = {
|
|
61
|
+
/** The process working directory. */
|
|
62
|
+
cwd: string;
|
|
63
|
+
/** `LEGLAS_PROJECT_DIR`: an explicit answer that ends the search. */
|
|
64
|
+
override?: string | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* `LEGLAS_PLUGIN_ROOT`: where an Agent Plugins client installed us, which
|
|
67
|
+
* the plugin's own `mcp.json` passes as `${PLUGIN_ROOT}`. Its only use is
|
|
68
|
+
* recognising a working directory that means nothing.
|
|
69
|
+
*/
|
|
70
|
+
pluginRoot?: string | undefined;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Worded for every way the search comes up empty: a host with no roots, a host
|
|
74
|
+
* with an empty workspace, and a host that failed to answer. Naming only the
|
|
75
|
+
* first would describe the others wrongly.
|
|
76
|
+
*/
|
|
77
|
+
export declare const UNRESOLVED_PROJECT: string;
|
|
78
|
+
export declare function hostProject(host: RootsHost, options: HostProjectOptions): Project;
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/** A project fixed to one directory: what an embedder or a test already knows. */
|
|
5
|
+
export function fixedProject(directory) {
|
|
6
|
+
const located = { ok: true, directory };
|
|
7
|
+
return { locate: async () => located };
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Worded for every way the search comes up empty: a host with no roots, a host
|
|
11
|
+
* with an empty workspace, and a host that failed to answer. Naming only the
|
|
12
|
+
* first would describe the others wrongly.
|
|
13
|
+
*/
|
|
14
|
+
export const UNRESOLVED_PROJECT = "Leglas could not tell which project to work in. This agent host started the " +
|
|
15
|
+
"Leglas MCP server in the plugin's own directory and named no workspace to " +
|
|
16
|
+
"work in, so there is no project here to act on. Set LEGLAS_PROJECT_DIR to " +
|
|
17
|
+
"the project directory, or run the leglas CLI in the project instead.";
|
|
18
|
+
export function hostProject(host, options) {
|
|
19
|
+
let pending = null;
|
|
20
|
+
return { locate: () => (pending ??= discover(host, options)) };
|
|
21
|
+
}
|
|
22
|
+
async function discover(host, options) {
|
|
23
|
+
const override = options.override?.trim();
|
|
24
|
+
if (override !== undefined && override !== "") {
|
|
25
|
+
return { ok: true, directory: resolve(override) };
|
|
26
|
+
}
|
|
27
|
+
const cwd = canonical(options.cwd);
|
|
28
|
+
const roots = await declaredRoots(host);
|
|
29
|
+
if (roots.some((root) => contains(canonical(root), cwd))) {
|
|
30
|
+
return { ok: true, directory: options.cwd };
|
|
31
|
+
}
|
|
32
|
+
const first = roots[0];
|
|
33
|
+
if (first !== undefined)
|
|
34
|
+
return { ok: true, directory: first };
|
|
35
|
+
const pluginRoot = options.pluginRoot?.trim();
|
|
36
|
+
// A client that does not expand ${PLUGIN_ROOT} leaves the literal behind; it
|
|
37
|
+
// matches no real directory, so the check simply does not fire.
|
|
38
|
+
if (pluginRoot !== undefined && pluginRoot !== "" && contains(canonical(pluginRoot), cwd)) {
|
|
39
|
+
return { ok: false, reason: UNRESOLVED_PROJECT };
|
|
40
|
+
}
|
|
41
|
+
return { ok: true, directory: options.cwd };
|
|
42
|
+
}
|
|
43
|
+
async function declaredRoots(host) {
|
|
44
|
+
await initialized(host);
|
|
45
|
+
if (host.getClientCapabilities()?.roots === undefined)
|
|
46
|
+
return [];
|
|
47
|
+
try {
|
|
48
|
+
const { roots } = await host.listRoots();
|
|
49
|
+
return roots
|
|
50
|
+
.map((root) => toDirectory(root.uri))
|
|
51
|
+
.filter((directory) => directory !== null);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// A host that advertises roots and then refuses to list them has told us
|
|
55
|
+
// nothing, which leaves the working directory as good a guess as before.
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Roots can only be asked for once the client has initialized; asking earlier
|
|
61
|
+
* is a protocol error. A stdio server is connected well before a host gets
|
|
62
|
+
* around to initializing, and the channel starts polling immediately, so this
|
|
63
|
+
* wait is the ordinary path rather than an edge case.
|
|
64
|
+
*/
|
|
65
|
+
function initialized(host) {
|
|
66
|
+
if (host.getClientCapabilities() !== undefined)
|
|
67
|
+
return Promise.resolve();
|
|
68
|
+
return new Promise((ready) => {
|
|
69
|
+
const previous = host.oninitialized;
|
|
70
|
+
host.oninitialized = () => {
|
|
71
|
+
previous?.();
|
|
72
|
+
ready();
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** Roots are file URIs by the spec; a host that sends a bare path still reads. */
|
|
77
|
+
function toDirectory(uri) {
|
|
78
|
+
if (uri.startsWith("file:")) {
|
|
79
|
+
try {
|
|
80
|
+
return fileURLToPath(uri);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return isAbsolute(uri) ? uri : null;
|
|
87
|
+
}
|
|
88
|
+
function contains(parent, child) {
|
|
89
|
+
if (child === parent)
|
|
90
|
+
return true;
|
|
91
|
+
return child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
|
|
92
|
+
}
|
|
93
|
+
/** Compared paths are real paths: /tmp is a symlink on macOS, among others. */
|
|
94
|
+
function canonical(directory) {
|
|
95
|
+
const absolute = resolve(directory);
|
|
96
|
+
try {
|
|
97
|
+
return realpathSync(absolute);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return absolute;
|
|
101
|
+
}
|
|
102
|
+
}
|
package/dist/tools.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { Project } from "./project.js";
|
|
2
3
|
export type LeglasTools = {
|
|
3
4
|
/** Stop anything the tools started. Wired to the transport's close. */
|
|
4
5
|
shutdown(): Promise<void>;
|
|
5
6
|
};
|
|
6
7
|
export declare function registerLeglasTools(server: McpServer, options: {
|
|
7
|
-
|
|
8
|
+
project: Project;
|
|
8
9
|
}): LeglasTools;
|
package/dist/tools.js
CHANGED
|
@@ -23,8 +23,24 @@ async function capture(invoke) {
|
|
|
23
23
|
}
|
|
24
24
|
return { content: [{ type: "text", text: lines.join("\n") }], isError: exitCode !== 0 };
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Run a command in the project, or report that there is no project to run it
|
|
28
|
+
* in. Resolution is asked for on the first call and held after that, so the
|
|
29
|
+
* cost lands once and every tool acts on the same directory.
|
|
30
|
+
*/
|
|
31
|
+
async function inProject(project, invoke) {
|
|
32
|
+
const located = await project.locate();
|
|
33
|
+
if (!located.ok) {
|
|
34
|
+
// The CLI's shape for a failure, so a host parses this like any other.
|
|
35
|
+
return {
|
|
36
|
+
content: [{ type: "text", text: JSON.stringify({ ok: false, error: located.reason }) }],
|
|
37
|
+
isError: true,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return capture((deps) => invoke(located.directory, deps));
|
|
41
|
+
}
|
|
26
42
|
export function registerLeglasTools(server, options) {
|
|
27
|
-
const
|
|
43
|
+
const project = options.project;
|
|
28
44
|
// One viewer per MCP process. The handle is held so a host that dies or
|
|
29
45
|
// disconnects never leaves a dev server running on a port nobody remembers.
|
|
30
46
|
let viewer = null;
|
|
@@ -46,7 +62,7 @@ export function registerLeglasTools(server, options) {
|
|
|
46
62
|
],
|
|
47
63
|
};
|
|
48
64
|
}
|
|
49
|
-
return
|
|
65
|
+
return inProject(project, async (cwd, deps) => {
|
|
50
66
|
const result = await run({ port, userPort: undefined, configPath: undefined, open: false, json: true, cwd }, { open: async () => { }, log: deps.log });
|
|
51
67
|
viewer = result;
|
|
52
68
|
return result;
|
|
@@ -71,12 +87,12 @@ export function registerLeglasTools(server, options) {
|
|
|
71
87
|
basedOn: z.string().optional()
|
|
72
88
|
.describe("Title of the direction this is a variant of; the rail groups the family."),
|
|
73
89
|
},
|
|
74
|
-
}, async ({ title, url, note, tags, branch, file, basedOn }) =>
|
|
90
|
+
}, async ({ title, url, note, tags, branch, file, basedOn }) => inProject(project, (cwd, deps) => runAdd({ preview: { title, url, note, tags, branch, file, basedOn }, json: true, cwd }, deps)));
|
|
75
91
|
server.registerTool("list", {
|
|
76
92
|
title: "List previews",
|
|
77
93
|
description: "Every preview, shared and local, with its URL and backing branch if any.",
|
|
78
94
|
inputSchema: {},
|
|
79
|
-
}, async () =>
|
|
95
|
+
}, async () => inProject(project, (cwd, deps) => runList({ json: true, cwd }, deps)));
|
|
80
96
|
server.registerTool("show", {
|
|
81
97
|
title: "Inspect one direction",
|
|
82
98
|
description: "Everything Leglas knows about one direction: its full entry, the source file behind " +
|
|
@@ -89,7 +105,7 @@ export function registerLeglasTools(server, options) {
|
|
|
89
105
|
.min(1)
|
|
90
106
|
.describe("The direction's title as the config spells it, not a renamed display name."),
|
|
91
107
|
},
|
|
92
|
-
}, async ({ title }) =>
|
|
108
|
+
}, async ({ title }) => inProject(project, (cwd, deps) => runShow({ title, json: true, cwd }, deps)));
|
|
93
109
|
server.registerTool("classify", {
|
|
94
110
|
title: "Decide where a direction should live",
|
|
95
111
|
description: "Before writing a direction, declare what it will touch and learn whether it can be " +
|
|
@@ -103,7 +119,7 @@ export function registerLeglasTools(server, options) {
|
|
|
103
119
|
}))
|
|
104
120
|
.min(1),
|
|
105
121
|
},
|
|
106
|
-
}, async ({ changes }) =>
|
|
122
|
+
}, async ({ changes }) => inProject(project, (cwd, deps) => runClassify({ changes, json: true, cwd }, deps)));
|
|
107
123
|
server.registerTool("explore", {
|
|
108
124
|
title: "Brief an exploration",
|
|
109
125
|
description: "What a set for a surface needs and how it registers here. Directions must genuinely " +
|
|
@@ -118,7 +134,9 @@ export function registerLeglasTools(server, options) {
|
|
|
118
134
|
.optional()
|
|
119
135
|
.describe("An existing direction's title: ask for variants of it instead of new directions."),
|
|
120
136
|
},
|
|
121
|
-
}, async ({ surface, count, basedOn }) => capture((deps) =>
|
|
137
|
+
}, async ({ surface, count, basedOn }) => capture((deps) =>
|
|
138
|
+
// The brief is the same wherever it is read from; it touches no project.
|
|
139
|
+
runExplore({ surface, count: count ?? 3, basedOn: basedOn ?? null, json: true }, deps)));
|
|
122
140
|
server.registerTool("scaffold", {
|
|
123
141
|
title: "Scaffold a branch point",
|
|
124
142
|
description: "Create a switcher and a first direction for a surface under .leglas/variants/. " +
|
|
@@ -128,7 +146,7 @@ export function registerLeglasTools(server, options) {
|
|
|
128
146
|
from: z.string().optional().describe("Path of the component rendering this surface today."),
|
|
129
147
|
print: z.boolean().optional().describe("Print the scaffold instead of writing it."),
|
|
130
148
|
},
|
|
131
|
-
}, async ({ surface, from, print }) =>
|
|
149
|
+
}, async ({ surface, from, print }) => inProject(project, (cwd, deps) => runNew({ surface, print: print ?? false, json: true, from, cwd }, deps)));
|
|
132
150
|
server.registerTool("keep", {
|
|
133
151
|
title: "Keep a winner",
|
|
134
152
|
description: "Move the winning direction into real source, delete the rest of the exploration, and " +
|
|
@@ -137,24 +155,25 @@ export function registerLeglasTools(server, options) {
|
|
|
137
155
|
title: z.string().min(1).describe("Title of the direction to keep."),
|
|
138
156
|
to: z.string().min(1).describe("Path in real source where the winner should live."),
|
|
139
157
|
},
|
|
140
|
-
}, async ({ title, to }) =>
|
|
158
|
+
}, async ({ title, to }) => inProject(project, (cwd, deps) => runKeep({ title, to, json: true, cwd }, deps)));
|
|
141
159
|
server.registerTool("requests", {
|
|
142
160
|
title: "Collect change requests",
|
|
143
161
|
description: "Pending change requests made from the interface, each naming the direction and the file " +
|
|
144
162
|
"behind it. Call this when starting work in a project that uses Leglas, and again before " +
|
|
145
163
|
"changing any direction: the user may have described the change from the interface while " +
|
|
146
|
-
"you worked, and collecting marks it picked up there. Pass clear once they are done
|
|
164
|
+
"you worked, and collecting marks it picked up there. Pass clear once they are done: it " +
|
|
165
|
+
"drops what you collected and reports anything that arrived since, which is yours to do next.",
|
|
147
166
|
inputSchema: {
|
|
148
167
|
clear: z.boolean().optional(),
|
|
149
168
|
},
|
|
150
|
-
}, async ({ clear }) =>
|
|
169
|
+
}, async ({ clear }) => inProject(project, (cwd, deps) => runRequests({ json: true, clear: clear ?? false, cwd }, deps)));
|
|
151
170
|
server.registerTool("init", {
|
|
152
171
|
title: "Prepare a project",
|
|
153
172
|
description: "Write the AGENTS.md section, a starter config, and the gitignore entry into this project.",
|
|
154
173
|
inputSchema: {
|
|
155
174
|
force: z.boolean().optional().describe("Rewrite the AGENTS.md section if it exists."),
|
|
156
175
|
},
|
|
157
|
-
}, async ({ force }) =>
|
|
176
|
+
}, async ({ force }) => inProject(project, (cwd, deps) => runInit({ cwd, force: force ?? false, json: true }, deps)));
|
|
158
177
|
return {
|
|
159
178
|
shutdown: async () => {
|
|
160
179
|
const running = viewer;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leglas-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Stdio MCP server exposing Leglas to agent hosts that cannot run a shell.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
17
17
|
"zod": "^3.25.76",
|
|
18
|
-
"leglas": "^0.
|
|
18
|
+
"leglas": "^0.3.0"
|
|
19
19
|
},
|
|
20
20
|
"license": "MIT",
|
|
21
21
|
"repository": {
|