@stone-js/mcp-dev 0.8.8 → 0.8.10
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 +60 -12
- package/dist/McpDevServer.d.ts +1 -1
- package/dist/appContext.d.ts +60 -0
- package/dist/cli.d.ts +53 -0
- package/dist/cli.js +915 -0
- package/dist/commands/McpCommand.d.ts +1 -1
- package/dist/declarations.d.ts +16 -1
- package/dist/index.d.ts +11 -13
- package/dist/index.js +179 -109
- package/dist/introspection.d.ts +21 -2
- package/dist/knowledge.d.ts +1 -1
- package/dist/llms.d.ts +1 -1
- package/dist/mcpJson.d.ts +8 -2
- package/dist/tools.d.ts +1 -1
- package/package.json +15 -12
- package/dist/browser/decorators/McpDev.d.ts +0 -19
- package/dist/browser/options/McpDevBlueprint.d.ts +0 -18
- package/dist/browser.js +0 -37
- package/dist/decorators/McpDev.d.ts +0 -24
- package/dist/middleware/BlueprintMiddleware.d.ts +0 -17
- package/dist/options/McpDevBlueprint.d.ts +0 -34
package/dist/cli.js
ADDED
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { RuntimeError } from '@stone-js/core';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The platform tag the CLI adapter runs under. Re-declared locally (rather than imported) to keep
|
|
10
|
+
* this package decoupled from `@stone-js/node-cli-adapter`; the value must match the adapter's.
|
|
11
|
+
*/
|
|
12
|
+
const NODE_CONSOLE_PLATFORM = 'node_console';
|
|
13
|
+
/** The default MCP server name when the app declares none. */
|
|
14
|
+
const DEFAULT_MCP_SERVER_NAME = 'stone-mcp-dev';
|
|
15
|
+
/** The default MCP server version. */
|
|
16
|
+
const DEFAULT_MCP_SERVER_VERSION = '0.0.0';
|
|
17
|
+
/**
|
|
18
|
+
* The default `instructions` advertised to the agent: what this server is and how to use it.
|
|
19
|
+
*/
|
|
20
|
+
const DEFAULT_MCP_INSTRUCTIONS = [
|
|
21
|
+
'This MCP server exposes the Stone.js framework knowledge to help you build on it.',
|
|
22
|
+
'Use the `stone_*` tools to look up concepts, modules, best-practices, gaps and documentation',
|
|
23
|
+
'links before writing Stone.js code, so your answers match the framework\'s actual conventions.',
|
|
24
|
+
'Any additional tools are provided by the developer for this project.'
|
|
25
|
+
].join(' ');
|
|
26
|
+
|
|
27
|
+
/* v8 ignore start -- thin filesystem defaults */
|
|
28
|
+
const defaultIo = {
|
|
29
|
+
exists: (path) => existsSync(path),
|
|
30
|
+
read: (path) => readFileSync(path, 'utf-8'),
|
|
31
|
+
write: (path, content) => writeFileSync(path, content, 'utf-8')
|
|
32
|
+
};
|
|
33
|
+
/* v8 ignore stop */
|
|
34
|
+
/**
|
|
35
|
+
* The `.mcp.json` server entry that launches this dev server.
|
|
36
|
+
*
|
|
37
|
+
* It goes through `npx` because `@stone-js/cli` is a project dev dependency: a bare `stone` only
|
|
38
|
+
* resolves when the CLI is also installed globally, so the entry an agent spawns would fail with
|
|
39
|
+
* ENOENT on a normal project. `npx` resolves the project-local binary first, and still finds a
|
|
40
|
+
* global install, so the generated file works either way.
|
|
41
|
+
*
|
|
42
|
+
* @param command - The launcher command (defaults to `npx`).
|
|
43
|
+
* @param args - The launcher arguments (defaults to `stone mcp`).
|
|
44
|
+
* @returns The MCP server entry.
|
|
45
|
+
*/
|
|
46
|
+
function mcpServerEntry(command = 'npx', args = ['stone', 'mcp']) {
|
|
47
|
+
return { command, args };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Merge the `stone` server into an existing `.mcp.json` object without clobbering anything.
|
|
51
|
+
*
|
|
52
|
+
* It only adds the `stone` entry when absent, so a developer's own config (other servers, or a
|
|
53
|
+
* customized `stone` entry) is preserved.
|
|
54
|
+
*
|
|
55
|
+
* @param existing - The parsed `.mcp.json` (or undefined when the file does not exist).
|
|
56
|
+
* @returns The merged config and whether it changed.
|
|
57
|
+
*/
|
|
58
|
+
function mergeMcpJson(existing) {
|
|
59
|
+
const config = { ...existing };
|
|
60
|
+
const servers = { ...config.mcpServers };
|
|
61
|
+
const changed = servers.stone === undefined;
|
|
62
|
+
if (changed) {
|
|
63
|
+
servers.stone = mcpServerEntry();
|
|
64
|
+
}
|
|
65
|
+
config.mcpServers = servers;
|
|
66
|
+
return { config, changed };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Create or update `.mcp.json` at `cwd` so a coding agent discovers this server. Idempotent: it
|
|
70
|
+
* writes only when the `stone` entry is missing, and never overwrites the rest of the file.
|
|
71
|
+
*
|
|
72
|
+
* @param cwd - The project root.
|
|
73
|
+
* @param io - The filesystem surface (defaults to `node:fs`).
|
|
74
|
+
* @returns The file path and whether it was written.
|
|
75
|
+
*/
|
|
76
|
+
function initMcpJson(cwd, io = defaultIo) {
|
|
77
|
+
const file = join(cwd, '.mcp.json');
|
|
78
|
+
let existing;
|
|
79
|
+
if (io.exists(file)) {
|
|
80
|
+
try {
|
|
81
|
+
existing = JSON.parse(io.read(file));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
existing = undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const { config, changed } = mergeMcpJson(existing);
|
|
88
|
+
if (changed) {
|
|
89
|
+
io.write(file, `${JSON.stringify(config, null, 2)}\n`);
|
|
90
|
+
}
|
|
91
|
+
return { file, changed };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Whether a `.mcp.json` exists at `cwd`.
|
|
95
|
+
*
|
|
96
|
+
* @param cwd - The project root.
|
|
97
|
+
* @param io - The filesystem surface (defaults to `node:fs`).
|
|
98
|
+
* @returns True when the file exists.
|
|
99
|
+
*/
|
|
100
|
+
function hasMcpJson(cwd, io = defaultIo) {
|
|
101
|
+
return io.exists(join(cwd, '.mcp.json'));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Custom error for the MCP dev module.
|
|
106
|
+
*/
|
|
107
|
+
class McpDevError extends RuntimeError {
|
|
108
|
+
constructor(message, options = {}) {
|
|
109
|
+
super(message, options);
|
|
110
|
+
this.name = 'McpDevError';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const concepts = [
|
|
115
|
+
{ id: 'continuum', title: 'Continuum Architecture', summary: 'An application is not an artefact but an act: Application = Domain × Context → Resolution. Stone.js IS the context: you write your domain once and the context applies to it at runtime. Focus on your domain during development; choose where to deploy at the end.' },
|
|
116
|
+
{ id: 'domain-vs-context', title: 'Domain vs Context', summary: 'The domain is your business logic (handlers, services). The context is the execution environment (HTTP, CLI, browser, edge, MCP). Stone.js owns the context so a single domain runs in any of them — backend, frontend, and mobile later.' },
|
|
117
|
+
{ id: 'blueprint', title: 'Blueprint (Setup)', summary: 'A single configuration manifest built once before any event, by introspecting decorators or via imperative meta-modules. All configuration lives under dotted `stone.*` keys.' },
|
|
118
|
+
{ id: 'kernel', title: 'Kernel (Initialization)', summary: 'Applies the container (an ephemeral per-event execution context) and the domain to the intention; runs middleware, hooks and error handlers. The micro-kernel depends only on pipeline, service-container and config.' },
|
|
119
|
+
{ id: 'adapter', title: 'Adapter (Integration)', summary: 'One package per platform. Captures raw causes, normalises them into intentions (IncomingEvent), and turns responses back into native effects. The adapter is the only long-lived, shared, launched-once thing.' },
|
|
120
|
+
{ id: 'ephemeral-context', title: 'Ephemeral per-request context', summary: 'Each request creates a fresh container (a new ephemeral context) via the kernel — totally isolated, never shared between requests. App-lifetime state belongs in a shared scope (a module-level singleton, a cache, or the adapter), not the container.' },
|
|
121
|
+
{ id: 'two-paradigms', title: 'Two paradigms at parity', summary: 'Declarative (TC39 stage-3 decorators, Symbol.metadata) and imperative (define* helpers → meta-modules). Both are first-class and 1:1. Three forms everywhere: class, factory, function (the function form never receives the container).' },
|
|
122
|
+
{ id: 'service-container', title: 'Service container (DI)', summary: 'A Proxy-based container whose `get` auto-wires dependencies from a destructured constructor (`constructor ({ logger, telemetry })`). Register services as class, factory (singleton optional), never the function form for providers.' },
|
|
123
|
+
{ id: 'service-provider', title: 'Service provider', summary: 'Registers services/bindings into the container during kernel init. Modules contribute providers via their blueprint; nothing depends back on the core (micro-kernel).' },
|
|
124
|
+
{ id: 'middleware', title: 'Middleware & hooks', summary: 'A chain-of-responsibility pipeline wraps event handling (global > local priority). Lifecycle hooks (onInit, onEvent, onTerminate, …) let modules observe the flow without coupling.' }
|
|
125
|
+
];
|
|
126
|
+
const modules = [
|
|
127
|
+
{ package: '@stone-js/pipeline', summary: 'Chain-of-responsibility primitive.', tier: 'primitive' },
|
|
128
|
+
{ package: '@stone-js/service-container', summary: 'Proxy-based dependency-injection container.', tier: 'primitive' },
|
|
129
|
+
{ package: '@stone-js/config', summary: 'Dotted-key blueprint store.', tier: 'primitive' },
|
|
130
|
+
{ package: '@stone-js/core', summary: 'The micro-kernel: blueprint, kernel, adapter base, lifecycle.', tier: 'core' },
|
|
131
|
+
{ package: '@stone-js/http-core', summary: 'Runtime-agnostic HTTP primitives (events, responses, cookies).', tier: 'crosscutting' },
|
|
132
|
+
{ package: '@stone-js/router', summary: 'Universal router (node & browser).', tier: 'crosscutting' },
|
|
133
|
+
{ package: '@stone-js/env', summary: 'Environment access with masking.', tier: 'crosscutting' },
|
|
134
|
+
{ package: '@stone-js/filesystem', summary: 'Filesystem + file abstractions.', tier: 'crosscutting' },
|
|
135
|
+
{ package: '@stone-js/cache', summary: 'Platform-agnostic caching with pluggable stores.', tier: 'crosscutting' },
|
|
136
|
+
{ package: '@stone-js/cloud-file', summary: 'Cloud object-storage drivers extending the filesystem abstractions.', tier: 'crosscutting' },
|
|
137
|
+
{ package: '@stone-js/i18n', summary: 'Runtime localization and translation services.', tier: 'crosscutting' },
|
|
138
|
+
{ package: '@stone-js/queue', summary: 'Background job queues with pluggable drivers.', tier: 'crosscutting' },
|
|
139
|
+
{ package: '@stone-js/realtime', summary: 'Realtime channels, rooms and presence.', tier: 'crosscutting' },
|
|
140
|
+
{ package: '@stone-js/browser-core', summary: 'Browser-side primitives.', tier: 'crosscutting' },
|
|
141
|
+
{ package: '@stone-js/node-http-adapter', summary: 'Node HTTP server adapter.', tier: 'adapter' },
|
|
142
|
+
{ package: '@stone-js/node-cli-adapter', summary: 'Node CLI adapter.', tier: 'adapter' },
|
|
143
|
+
{ package: '@stone-js/aws-lambda-adapter', summary: 'Generic AWS Lambda adapter.', tier: 'adapter' },
|
|
144
|
+
{ package: '@stone-js/aws-lambda-http-adapter', summary: 'AWS Lambda HTTP (API GW v1/v2, ALB) adapter.', tier: 'adapter' },
|
|
145
|
+
{ package: '@stone-js/browser-adapter', summary: 'Browser SPA adapter.', tier: 'adapter' },
|
|
146
|
+
{ package: '@stone-js/fetch-adapter', summary: 'Web-standard (WinterCG) adapter: one build → Cloudflare/Deno/Bun/Vercel/Netlify edge.', tier: 'adapter' },
|
|
147
|
+
{ package: '@stone-js/use-react', summary: 'React view engine (CSR/SSR/SSG).', tier: 'frontend' },
|
|
148
|
+
{ package: '@stone-js/use-view', summary: 'Agnostic view-engine layer.', tier: 'frontend' },
|
|
149
|
+
{ package: '@stone-js/telemetry', summary: 'Spans/counters/gauges via hooks + middleware, pluggable exporters.', tier: 'extension' },
|
|
150
|
+
{ package: '@stone-js/validation', summary: 'One schema (Zod/Standard Schema) validated backend AND frontend.', tier: 'extension' },
|
|
151
|
+
{ package: '@stone-js/auth', summary: 'Edge-native, stateless JWT/OAuth (jose).', tier: 'extension' },
|
|
152
|
+
{ package: '@stone-js/authz', summary: 'Isomorphic RBAC+ABAC authorization (CASL).', tier: 'extension' },
|
|
153
|
+
{ package: '@stone-js/resources', summary: 'API resources: shape the exposed output, decoupled from controllers.', tier: 'extension' },
|
|
154
|
+
{ package: '@stone-js/openapi', summary: 'Derive an OpenAPI contract from Zod schemas + routes.', tier: 'extension' },
|
|
155
|
+
{ package: '@stone-js/testing', summary: 'Boot an app in-memory and dispatch events through the kernel.', tier: 'extension' },
|
|
156
|
+
{ package: '@stone-js/mcp-dev', summary: 'Serve the framework knowledge + your tools to a coding agent via `stone mcp` (MCP, stdio).', tier: 'tooling' },
|
|
157
|
+
{ package: '@stone-js/cli', summary: 'Build tooling (Rollup+Babel backend, Vite+Babel frontend, codegen).', tier: 'tooling' },
|
|
158
|
+
{ package: '@stone-js/create', summary: 'Scaffolder: npm create @stone-js.', tier: 'tooling' }
|
|
159
|
+
];
|
|
160
|
+
const bestPractices = [
|
|
161
|
+
{ rule: 'Keep every module core platform-agnostic (no window/process/fs); add platform drivers/adapters around it.', why: 'A single domain must run backend, frontend and (later) mobile.' },
|
|
162
|
+
{ rule: 'Use TC39 stage-3 decorators (Symbol.metadata). Never enable experimentalDecorators or reflect-metadata.', why: 'Setting experimentalDecorators flips esbuild/tsc to legacy decorators and breaks method decorators; the CLI builds with Babel stage-3.' },
|
|
163
|
+
{ rule: 'Configure everything via dotted stone.* keys on the blueprint.', why: 'One uniform, introspectable configuration surface.' },
|
|
164
|
+
{ rule: 'Expose class, factory and function forms; the function form never receives the container.', why: 'Two paradigms at parity, DI only where it makes sense.' },
|
|
165
|
+
{ rule: 'Private/protected constructor + static create().', why: 'Controlled construction across the framework.' },
|
|
166
|
+
{ rule: 'Declare internal @stone-js/* deps as workspace:* in the monorepo.', why: 'Avoids resolving stale published versions (a real source of breakage).' },
|
|
167
|
+
{ rule: 'Put app-lifetime state in a shared scope (module-level singleton, cache, adapter), never in the per-request container.', why: 'The container is a fresh ephemeral context per request — it cannot and must not persist across requests.' },
|
|
168
|
+
{ rule: 'Every bug fix earns a behavioural test (not a mock test); target 100% coverage.', why: 'Prove behaviour, not implementation.' },
|
|
169
|
+
{ rule: 'Attach request-scoped state via setMetadataValue/getMetadataValue; the principal via setUserResolver.', why: 'The idiomatic per-event carriers.' }
|
|
170
|
+
];
|
|
171
|
+
const gaps = [
|
|
172
|
+
{ name: 'mail/notifications', status: 'planned', note: 'Multi-channel notifications.' },
|
|
173
|
+
{ name: 'rate-limiting', status: 'planned', note: 'Edge-friendly throttling.' },
|
|
174
|
+
{ name: 'ORM', status: 'missing', note: 'By design: integrate Drizzle/Prisma/Kysely via providers — Stone.js will not ship an ORM.' }
|
|
175
|
+
];
|
|
176
|
+
/**
|
|
177
|
+
* The single, curated, machine-readable map of Stone.js. Kept concise and accurate so an agent
|
|
178
|
+
* can consult it in real time instead of scanning every package.
|
|
179
|
+
*/
|
|
180
|
+
const knowledgeBase = {
|
|
181
|
+
name: 'Stone.js',
|
|
182
|
+
tagline: 'Focus on your domain. Stone.js is the context. Build once, deploy anywhere.',
|
|
183
|
+
version: '0.8.0',
|
|
184
|
+
concepts,
|
|
185
|
+
modules,
|
|
186
|
+
bestPractices,
|
|
187
|
+
gaps
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* Find a concept by id (case-insensitive).
|
|
191
|
+
*
|
|
192
|
+
* @param id - The concept id.
|
|
193
|
+
* @returns The concept, or undefined.
|
|
194
|
+
*/
|
|
195
|
+
function getConcept(id) {
|
|
196
|
+
return knowledgeBase.concepts.find((concept) => concept.id === id.toLowerCase());
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Full-text search across concepts, modules, best-practices and gaps.
|
|
200
|
+
*
|
|
201
|
+
* @param query - The search terms.
|
|
202
|
+
* @returns Matching entries with their kind.
|
|
203
|
+
*/
|
|
204
|
+
function searchKnowledge(query) {
|
|
205
|
+
const q = query.trim().toLowerCase();
|
|
206
|
+
if (q.length === 0) {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
const results = [];
|
|
210
|
+
const match = (text) => text.toLowerCase().includes(q);
|
|
211
|
+
for (const c of knowledgeBase.concepts) {
|
|
212
|
+
if (match(c.id) || match(c.title) || match(c.summary)) {
|
|
213
|
+
results.push({ kind: 'concept', title: c.title, text: c.summary });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const m of knowledgeBase.modules) {
|
|
217
|
+
if (match(m.package) || match(m.summary)) {
|
|
218
|
+
results.push({ kind: 'module', title: m.package, text: m.summary });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
for (const b of knowledgeBase.bestPractices) {
|
|
222
|
+
if (match(b.rule) || match(b.why)) {
|
|
223
|
+
results.push({ kind: 'best-practice', title: b.rule, text: b.why });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
for (const g of knowledgeBase.gaps) {
|
|
227
|
+
if (match(g.name) || match(g.note)) {
|
|
228
|
+
results.push({ kind: 'gap', title: g.name, text: g.note });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return results;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Generates the concise `llms.txt` index (the emerging standard: a short, link-friendly Markdown
|
|
236
|
+
* map an agent can read in one shot). Serve it at `/llms.txt` from the docs site.
|
|
237
|
+
*
|
|
238
|
+
* @param base - The knowledge base (defaults to the built-in one).
|
|
239
|
+
* @returns The `llms.txt` content.
|
|
240
|
+
*/
|
|
241
|
+
function generateLlmsTxt(base = knowledgeBase) {
|
|
242
|
+
const concepts = base.concepts.map((c) => `- **${c.title}**: ${c.summary}`).join('\n');
|
|
243
|
+
const modules = base.modules.map((m) => `- \`${m.package}\` (${m.tier}): ${m.summary}`).join('\n');
|
|
244
|
+
return `# ${base.name}
|
|
245
|
+
|
|
246
|
+
> ${base.tagline} (v${base.version})
|
|
247
|
+
|
|
248
|
+
## Core concepts
|
|
249
|
+
|
|
250
|
+
${concepts}
|
|
251
|
+
|
|
252
|
+
## Modules
|
|
253
|
+
|
|
254
|
+
${modules}
|
|
255
|
+
`;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Generates the fuller `llms-full.txt` (adds best-practices and known gaps) — the complete brief
|
|
259
|
+
* for an agent building with Stone.js.
|
|
260
|
+
*
|
|
261
|
+
* @param base - The knowledge base (defaults to the built-in one).
|
|
262
|
+
* @returns The `llms-full.txt` content.
|
|
263
|
+
*/
|
|
264
|
+
function generateLlmsFullTxt(base = knowledgeBase) {
|
|
265
|
+
const bestPractices = base.bestPractices.map((b) => `- ${b.rule}\n - Why: ${b.why}`).join('\n');
|
|
266
|
+
const gaps = base.gaps.map((g) => `- **${g.name}** (${g.status}): ${g.note}`).join('\n');
|
|
267
|
+
return `${generateLlmsTxt(base)}
|
|
268
|
+
## Best practices
|
|
269
|
+
|
|
270
|
+
${bestPractices}
|
|
271
|
+
|
|
272
|
+
## Known gaps (what to reach for a third party or the roadmap)
|
|
273
|
+
|
|
274
|
+
${gaps}
|
|
275
|
+
`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The Stone.js framework-knowledge tools served by `stone mcp`. They are registered on the MCP
|
|
280
|
+
* server automatically; point your coding agent at it and it can query the framework in real time
|
|
281
|
+
* (concepts, modules, best-practices, gaps) instead of scanning every package.
|
|
282
|
+
*/
|
|
283
|
+
const stoneMcpTools = [
|
|
284
|
+
{
|
|
285
|
+
name: 'stone_search',
|
|
286
|
+
description: 'Search the Stone.js knowledge base (concepts, modules, best-practices, gaps).',
|
|
287
|
+
inputSchema: {
|
|
288
|
+
query: z.string().describe('What to look for, matched against concepts, modules, best practices and gaps.')
|
|
289
|
+
},
|
|
290
|
+
handler: (args) => searchKnowledge(String(args.query ?? ''))
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: 'stone_concept',
|
|
294
|
+
description: 'Explain a core Stone.js concept by id (omit id to list them all).',
|
|
295
|
+
inputSchema: {
|
|
296
|
+
id: z.string().optional().describe('The concept id. Omit it to list every concept instead.')
|
|
297
|
+
},
|
|
298
|
+
handler: (args) => {
|
|
299
|
+
const id = String(args.id ?? '');
|
|
300
|
+
if (id.length === 0) {
|
|
301
|
+
return knowledgeBase.concepts.map((c) => ({ id: c.id, title: c.title }));
|
|
302
|
+
}
|
|
303
|
+
return getConcept(id) ?? { error: `Unknown concept: ${id}` };
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
name: 'stone_modules',
|
|
308
|
+
description: 'List the Stone.js ecosystem modules and what each does.',
|
|
309
|
+
handler: () => knowledgeBase.modules
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
name: 'stone_best_practices',
|
|
313
|
+
description: 'List Stone.js conventions and anti-patterns, each with its rationale.',
|
|
314
|
+
handler: () => knowledgeBase.bestPractices
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
name: 'stone_gaps',
|
|
318
|
+
description: 'List what Stone.js does not (yet) provide, and what to reach for instead.',
|
|
319
|
+
handler: () => knowledgeBase.gaps
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
name: 'stone_brief',
|
|
323
|
+
description: 'Return the full agent brief (llms-full.txt): concepts, modules, best-practices, gaps.',
|
|
324
|
+
handler: () => generateLlmsFullTxt()
|
|
325
|
+
}
|
|
326
|
+
];
|
|
327
|
+
/**
|
|
328
|
+
* Creates tools that let an agent (or the developer through it) report a bug or request a feature
|
|
329
|
+
* as a real GitHub issue, straight from the dev loop.
|
|
330
|
+
*
|
|
331
|
+
* @param options - The GitHub token and target repository.
|
|
332
|
+
* @returns The report tools.
|
|
333
|
+
*/
|
|
334
|
+
function createReportTools(options) {
|
|
335
|
+
const doFetch = options.fetch ?? fetch;
|
|
336
|
+
const openIssue = async (title, body, label) => {
|
|
337
|
+
const response = await doFetch(`https://api.github.com/repos/${options.repo}/issues`, {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
headers: {
|
|
340
|
+
authorization: `Bearer ${options.token}`,
|
|
341
|
+
accept: 'application/vnd.github+json',
|
|
342
|
+
'content-type': 'application/json'
|
|
343
|
+
},
|
|
344
|
+
body: JSON.stringify({ title, body, labels: [label] })
|
|
345
|
+
});
|
|
346
|
+
if (!response.ok) {
|
|
347
|
+
return { error: `GitHub API error: ${response.status}` };
|
|
348
|
+
}
|
|
349
|
+
const issue = await response.json();
|
|
350
|
+
return { number: issue.number, url: issue.html_url };
|
|
351
|
+
};
|
|
352
|
+
return [
|
|
353
|
+
{
|
|
354
|
+
name: 'stone_report_bug',
|
|
355
|
+
description: 'Open a bug report as a GitHub issue on the Stone.js repository.',
|
|
356
|
+
inputSchema: {
|
|
357
|
+
title: z.string().describe('One line naming the defect.'),
|
|
358
|
+
body: z.string().describe('What happens, what was expected, and how to reproduce it.')
|
|
359
|
+
},
|
|
360
|
+
handler: async (args) => await openIssue(String(args.title ?? 'Bug report'), String(args.body ?? ''), 'bug')
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
name: 'stone_request_feature',
|
|
364
|
+
description: 'Open a feature request as a GitHub issue on the Stone.js repository.',
|
|
365
|
+
inputSchema: {
|
|
366
|
+
title: z.string().describe('One line naming the feature.'),
|
|
367
|
+
body: z.string().describe('The problem it solves, and how it should behave.')
|
|
368
|
+
},
|
|
369
|
+
handler: async (args) => await openIssue(String(args.title ?? 'Feature request'), String(args.body ?? ''), 'enhancement')
|
|
370
|
+
}
|
|
371
|
+
];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Wrap any handler return value as MCP text content (JSON for structured data).
|
|
376
|
+
*
|
|
377
|
+
* @param result - The value a tool handler returned.
|
|
378
|
+
* @returns The MCP content payload.
|
|
379
|
+
*/
|
|
380
|
+
function toToolContent(result) {
|
|
381
|
+
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
|
382
|
+
return { content: [{ type: 'text', text }] };
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Create the activity logger. It writes to **stderr** (never stdout, which the stdio transport
|
|
386
|
+
* reserves for the JSON-RPC protocol); a no-op when `quiet` is set.
|
|
387
|
+
*
|
|
388
|
+
* @param quiet - Silence the log.
|
|
389
|
+
* @returns The logger.
|
|
390
|
+
*/
|
|
391
|
+
function createStderrLogger(quiet = false) {
|
|
392
|
+
return (message) => {
|
|
393
|
+
if (!quiet) {
|
|
394
|
+
process.stderr.write(`${message}\n`);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Resolve the full tool list: the built-in framework-knowledge tools, the optional GitHub report
|
|
400
|
+
* tools, then the app's own tools.
|
|
401
|
+
*
|
|
402
|
+
* @param options - The dev-server options.
|
|
403
|
+
* @returns The merged tool list.
|
|
404
|
+
*/
|
|
405
|
+
function resolveTools(options) {
|
|
406
|
+
return [
|
|
407
|
+
...stoneMcpTools,
|
|
408
|
+
...(options.report !== undefined ? createReportTools(options.report) : []),
|
|
409
|
+
...(options.tools ?? [])
|
|
410
|
+
];
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Build the SDK callback for one tool: log the call to stderr, run the handler, log the outcome,
|
|
414
|
+
* and wrap the result (or the error) as MCP content.
|
|
415
|
+
*
|
|
416
|
+
* @param tool - The tool definition.
|
|
417
|
+
* @param log - The activity logger.
|
|
418
|
+
* @returns The SDK tool callback.
|
|
419
|
+
*/
|
|
420
|
+
function createToolCallback(tool, log) {
|
|
421
|
+
return async (args) => {
|
|
422
|
+
const input = args ?? {};
|
|
423
|
+
log(`→ ${tool.name}(${JSON.stringify(input)})`);
|
|
424
|
+
try {
|
|
425
|
+
const result = await tool.handler(input);
|
|
426
|
+
log(`← ${tool.name} ok`);
|
|
427
|
+
return toToolContent(result);
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
431
|
+
log(`← ${tool.name} error: ${message}`);
|
|
432
|
+
return { ...toToolContent({ error: message }), isError: true };
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Build a fully-configured MCP server: advertise the instructions and register every resolved tool
|
|
438
|
+
* with its logging callback. The handlers run in-process (dev/knowledge helpers, not the domain).
|
|
439
|
+
*
|
|
440
|
+
* @param options - The dev-server options.
|
|
441
|
+
* @param log - The activity logger.
|
|
442
|
+
* @returns The configured MCP server.
|
|
443
|
+
*/
|
|
444
|
+
function buildMcpServer(options, log) {
|
|
445
|
+
const server = new McpServer({ name: options.name ?? DEFAULT_MCP_SERVER_NAME, version: options.version ?? DEFAULT_MCP_SERVER_VERSION }, { instructions: options.instructions ?? DEFAULT_MCP_INSTRUCTIONS });
|
|
446
|
+
for (const tool of resolveTools(options)) {
|
|
447
|
+
// A tool with no schema is registered with none at all, rather than with an empty one: both are
|
|
448
|
+
// advertised as taking no arguments, but the empty shape reads like a declared contract when it
|
|
449
|
+
// is the absence of one.
|
|
450
|
+
server.registerTool(tool.name, { description: tool.description, inputSchema: tool.inputSchema }, createToolCallback(tool, log));
|
|
451
|
+
}
|
|
452
|
+
return server;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Start the MCP dev server over stdio and keep it alive until the process is interrupted.
|
|
456
|
+
*
|
|
457
|
+
* The stdio transport speaks JSON-RPC on stdout and keeps the event loop alive by reading stdin,
|
|
458
|
+
* so `Ctrl+C` (SIGINT) stops it, exactly like `stone dev`.
|
|
459
|
+
*
|
|
460
|
+
* @param options - The dev-server options.
|
|
461
|
+
*/
|
|
462
|
+
/* v8 ignore start -- process lifecycle: stdio transport + signal handling, not unit-testable */
|
|
463
|
+
async function startMcpDevServer(options) {
|
|
464
|
+
const log = createStderrLogger(options.quiet);
|
|
465
|
+
const server = buildMcpServer(options, log);
|
|
466
|
+
const transport = new StdioServerTransport();
|
|
467
|
+
const shutdown = () => {
|
|
468
|
+
log('mcp: shutting down');
|
|
469
|
+
void server.close().finally(() => process.exit(0));
|
|
470
|
+
};
|
|
471
|
+
process.once('SIGINT', shutdown);
|
|
472
|
+
process.once('SIGTERM', shutdown);
|
|
473
|
+
if (!hasMcpJson(process.cwd())) {
|
|
474
|
+
log('mcp: no .mcp.json found — run `stone mcp --init` to register this server for your agent');
|
|
475
|
+
}
|
|
476
|
+
await server.connect(transport);
|
|
477
|
+
log(`mcp: ${resolveTools(options).length} tools ready on stdio — press Ctrl+C to stop`);
|
|
478
|
+
}
|
|
479
|
+
/* v8 ignore stop */
|
|
480
|
+
|
|
481
|
+
/** Where a running application leaves its resolved configuration for the MCP server to read. */
|
|
482
|
+
const APP_CONTEXT_FILE = join('.stone', 'app-context.json');
|
|
483
|
+
/**
|
|
484
|
+
* Read what a running application published, if it published anything.
|
|
485
|
+
*
|
|
486
|
+
* @param cwd - The project root.
|
|
487
|
+
* @returns The context, or `undefined` when no application has run.
|
|
488
|
+
*/
|
|
489
|
+
function readAppContext(cwd = process.cwd()) {
|
|
490
|
+
const path = join(cwd, APP_CONTEXT_FILE);
|
|
491
|
+
if (!existsSync(path)) {
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
// A half-written or hand-edited file is not worth failing the whole MCP server for: the tools
|
|
499
|
+
// fall back to what the console boot knows, and say so.
|
|
500
|
+
return undefined;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* A reader over a published context, answering the same dotted keys a blueprint answers.
|
|
505
|
+
*
|
|
506
|
+
* @param context - The published context.
|
|
507
|
+
* @returns The reader.
|
|
508
|
+
*/
|
|
509
|
+
function contextReader(context) {
|
|
510
|
+
return {
|
|
511
|
+
get: (key, fallback) => {
|
|
512
|
+
const value = key.split('.').reduce((current, segment) => (typeof current === 'object' && current !== null)
|
|
513
|
+
? current[segment]
|
|
514
|
+
: undefined, { stone: context.stone });
|
|
515
|
+
return (value ?? fallback);
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Keys whose values are redacted from any config dump (env secrets, credentials). */
|
|
521
|
+
const SECRET_KEY = /(secret|token|password|passwd|api[_-]?key|credential|private|passphrase|auth)/i;
|
|
522
|
+
/** How deep {@link sanitize} walks before bailing out. */
|
|
523
|
+
const MAX_DEPTH = 6;
|
|
524
|
+
/**
|
|
525
|
+
* Best-effort name of a module reference (class, function, or meta-module `{ module }`).
|
|
526
|
+
*
|
|
527
|
+
* @param value - The reference to name.
|
|
528
|
+
* @returns The resolved name.
|
|
529
|
+
*/
|
|
530
|
+
function moduleName(value) {
|
|
531
|
+
if (value === undefined || value === null) {
|
|
532
|
+
return 'unknown';
|
|
533
|
+
}
|
|
534
|
+
if (typeof value === 'string') {
|
|
535
|
+
return value;
|
|
536
|
+
}
|
|
537
|
+
if (typeof value === 'function') {
|
|
538
|
+
return value.name.length > 0 ? value.name : 'anonymous';
|
|
539
|
+
}
|
|
540
|
+
if (typeof value === 'object') {
|
|
541
|
+
const meta = value;
|
|
542
|
+
if (meta.module !== undefined) {
|
|
543
|
+
return moduleName(meta.module);
|
|
544
|
+
}
|
|
545
|
+
if (typeof meta.name === 'string') {
|
|
546
|
+
return meta.name;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return 'unknown';
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Produce a JSON-safe, secret-redacted copy of a config value: functions/classes become a label,
|
|
553
|
+
* `RegExp` its source, secret-looking keys `[redacted]`, and recursion is depth-capped.
|
|
554
|
+
*
|
|
555
|
+
* @param value - The value to sanitize.
|
|
556
|
+
* @param depth - The current recursion depth.
|
|
557
|
+
* @returns A serializable value.
|
|
558
|
+
*/
|
|
559
|
+
function sanitize(value, depth = 0) {
|
|
560
|
+
if (depth > MAX_DEPTH) {
|
|
561
|
+
return '[max-depth]';
|
|
562
|
+
}
|
|
563
|
+
if (value === undefined || value === null) {
|
|
564
|
+
return value;
|
|
565
|
+
}
|
|
566
|
+
if (typeof value === 'function') {
|
|
567
|
+
return `[Function: ${value.name.length > 0 ? value.name : 'anonymous'}]`;
|
|
568
|
+
}
|
|
569
|
+
if (value instanceof RegExp) {
|
|
570
|
+
return value.toString();
|
|
571
|
+
}
|
|
572
|
+
if (Array.isArray(value)) {
|
|
573
|
+
return value.map((item) => sanitize(item, depth + 1));
|
|
574
|
+
}
|
|
575
|
+
if (typeof value === 'object') {
|
|
576
|
+
const out = {};
|
|
577
|
+
for (const [key, val] of Object.entries(value)) {
|
|
578
|
+
out[key] = SECRET_KEY.test(key) ? '[redacted]' : sanitize(val, depth + 1);
|
|
579
|
+
}
|
|
580
|
+
return out;
|
|
581
|
+
}
|
|
582
|
+
return value;
|
|
583
|
+
}
|
|
584
|
+
/** Remove `undefined` and empty arrays from an object, so tool output stays terse. */
|
|
585
|
+
function clean(obj) {
|
|
586
|
+
const out = {};
|
|
587
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
588
|
+
if (val === undefined) {
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
if (Array.isArray(val) && val.length === 0) {
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
out[key] = val;
|
|
595
|
+
}
|
|
596
|
+
return out;
|
|
597
|
+
}
|
|
598
|
+
/** Map a route definition (and its children) to a terse, serializable shape. */
|
|
599
|
+
function mapRoute(def) {
|
|
600
|
+
const methods = def.methods ?? (def.method !== undefined ? [def.method] : undefined);
|
|
601
|
+
const children = def.children ?? [];
|
|
602
|
+
const middleware = def.middleware ?? [];
|
|
603
|
+
return clean({
|
|
604
|
+
name: def.name,
|
|
605
|
+
methods,
|
|
606
|
+
path: def.path,
|
|
607
|
+
handler: def.handler !== undefined ? moduleName(def.handler) : undefined,
|
|
608
|
+
middleware: middleware.map(moduleName),
|
|
609
|
+
children: children.map(mapRoute)
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
/** Count routes across the definition tree. */
|
|
613
|
+
function countRoutes(defs) {
|
|
614
|
+
return defs.reduce((total, def) => {
|
|
615
|
+
const children = def.children ?? [];
|
|
616
|
+
return total + 1 + countRoutes(children);
|
|
617
|
+
}, 0);
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* What the tools are describing, and how they know.
|
|
621
|
+
*
|
|
622
|
+
* The MCP server is a console command, so the blueprint it holds is the one a *console* boot resolves:
|
|
623
|
+
* its adapter, its response type and every platform-conditional contribution belong to a different
|
|
624
|
+
* application than the one running under `stone dev`. When the running application has published its
|
|
625
|
+
* own configuration, that is the better answer and it is used; otherwise the console boot still
|
|
626
|
+
* answers for everything platform-independent — routes, providers, the kernel handler — and says which
|
|
627
|
+
* of its answers not to trust, rather than pretending to be the running app.
|
|
628
|
+
*
|
|
629
|
+
* @param blueprint - The blueprint of the process the MCP server runs in.
|
|
630
|
+
* @param cwd - The project root.
|
|
631
|
+
* @returns The reader to introspect, and a description of it.
|
|
632
|
+
*/
|
|
633
|
+
function resolveSource(blueprint, cwd) {
|
|
634
|
+
const published = readAppContext(cwd);
|
|
635
|
+
if (published === undefined) {
|
|
636
|
+
return {
|
|
637
|
+
source: blueprint,
|
|
638
|
+
describes: {
|
|
639
|
+
source: 'console-boot',
|
|
640
|
+
platform: blueprint.get('stone.adapter.platform'),
|
|
641
|
+
accurate: ['stone_routes', 'stone_commands', 'stone_providers', 'stone_kernel', 'stone_key_routes'],
|
|
642
|
+
unreliable: ['stone_adapters', 'stone_config'],
|
|
643
|
+
why: 'No running application has published its configuration, so this describes what a console ' +
|
|
644
|
+
'boot resolves. Anything platform-dependent therefore belongs to the console platform, not ' +
|
|
645
|
+
'to the application you are running. Install `@stone-js/mcp-dev` as a devDependency and run ' +
|
|
646
|
+
`the app once (\`stone dev\`): the build injects a publisher that writes ${APP_CONTEXT_FILE}, ` +
|
|
647
|
+
'and these tools then describe that application.'
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
source: contextReader(published),
|
|
653
|
+
describes: {
|
|
654
|
+
source: 'running-app',
|
|
655
|
+
platform: published.platform,
|
|
656
|
+
env: published.env,
|
|
657
|
+
name: published.name,
|
|
658
|
+
file: APP_CONTEXT_FILE,
|
|
659
|
+
why: 'This describes the application that actually ran, as it resolved itself: its platform, its ' +
|
|
660
|
+
'adapters and its configuration. Values that change after boot, such as a `live` ' +
|
|
661
|
+
'configuration, are as of that boot.'
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Build the read-only introspection tools bound to the app's resolved blueprint.
|
|
667
|
+
*
|
|
668
|
+
* These expose what the app actually declares (routes, commands, adapters, providers, kernel
|
|
669
|
+
* pipeline, config) so a coding agent understands *this* app, not just the framework. They read
|
|
670
|
+
* only, never mutate, and redact secret-looking config values.
|
|
671
|
+
*
|
|
672
|
+
* @param blueprint - The resolved application blueprint.
|
|
673
|
+
* @returns The introspection tools.
|
|
674
|
+
*/
|
|
675
|
+
function createIntrospectionTools(blueprint, cwd) {
|
|
676
|
+
const { source, describes } = resolveSource(blueprint, cwd);
|
|
677
|
+
const routes = () => source.get('stone.router.definitions', []);
|
|
678
|
+
const commands = () => source.get('stone.adapter.commands', []);
|
|
679
|
+
return [
|
|
680
|
+
{
|
|
681
|
+
name: 'stone_describes',
|
|
682
|
+
description: 'Say which application the introspection tools are describing, and how they know.',
|
|
683
|
+
handler: () => describes
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
name: 'stone_app',
|
|
687
|
+
description: 'Summarize the current Stone.js app: name, env, active platform, and counts of routes/commands/providers/adapters.',
|
|
688
|
+
handler: () => clean({
|
|
689
|
+
name: source.get('stone.name'),
|
|
690
|
+
env: source.get('stone.env'),
|
|
691
|
+
platform: source.get('stone.adapter.platform'),
|
|
692
|
+
counts: {
|
|
693
|
+
routes: countRoutes(routes()),
|
|
694
|
+
commands: commands().length,
|
|
695
|
+
providers: source.get('stone.providers', []).length,
|
|
696
|
+
adapters: source.get('stone.adapters', []).length
|
|
697
|
+
}
|
|
698
|
+
})
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
name: 'stone_routes',
|
|
702
|
+
description: 'List the app\'s routes (path, methods, name, handler, middleware) as declared on the router.',
|
|
703
|
+
handler: () => routes().map(mapRoute)
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
name: 'stone_commands',
|
|
707
|
+
description: 'List the app\'s CLI commands (name, alias, args, description).',
|
|
708
|
+
handler: () => commands().map((c) => clean({
|
|
709
|
+
name: c.options?.name,
|
|
710
|
+
alias: c.options?.alias,
|
|
711
|
+
args: c.options?.args,
|
|
712
|
+
desc: c.options?.desc
|
|
713
|
+
}))
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
name: 'stone_adapters',
|
|
717
|
+
description: 'List the registered adapters (platform, alias, default/current) and the active platform.',
|
|
718
|
+
handler: () => ({
|
|
719
|
+
active: source.get('stone.adapter.platform'),
|
|
720
|
+
adapters: source.get('stone.adapters', []).map((a) => clean({
|
|
721
|
+
platform: a.platform,
|
|
722
|
+
alias: a.alias,
|
|
723
|
+
current: a.current,
|
|
724
|
+
default: a.default
|
|
725
|
+
}))
|
|
726
|
+
})
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
name: 'stone_providers',
|
|
730
|
+
description: 'List the app\'s service providers.',
|
|
731
|
+
handler: () => source.get('stone.providers', []).map(moduleName)
|
|
732
|
+
},
|
|
733
|
+
{
|
|
734
|
+
name: 'stone_kernel',
|
|
735
|
+
description: 'Show the kernel pipeline: the event handler, middleware, and registered error handlers.',
|
|
736
|
+
handler: () => {
|
|
737
|
+
const kernel = source.get('stone.kernel', {});
|
|
738
|
+
return clean({
|
|
739
|
+
eventHandler: kernel.eventHandler !== undefined ? moduleName(kernel.eventHandler) : undefined,
|
|
740
|
+
middleware: (kernel.middleware ?? []).map(moduleName),
|
|
741
|
+
errorHandlers: Object.keys(kernel.errorHandlers ?? {})
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
{
|
|
746
|
+
name: 'stone_key_routes',
|
|
747
|
+
description: 'List the key-routing definitions (event-bus / realtime / keyed events): key to handler.',
|
|
748
|
+
handler: () => source.get('stone.keyRouting.definitions', []).map((d) => clean({
|
|
749
|
+
key: d.key,
|
|
750
|
+
action: d.action,
|
|
751
|
+
handler: d.module !== undefined ? moduleName(d.module) : undefined
|
|
752
|
+
}))
|
|
753
|
+
},
|
|
754
|
+
{
|
|
755
|
+
name: 'stone_config',
|
|
756
|
+
description: 'Read a resolved config value by dotted key under `stone.*` (secrets redacted). Omit `key` to list the top-level `stone` keys.',
|
|
757
|
+
inputSchema: {
|
|
758
|
+
key: z.string().optional().describe('A dotted key such as `stone.router`. Omit it to list the top-level `stone` keys instead.')
|
|
759
|
+
},
|
|
760
|
+
handler: (args) => {
|
|
761
|
+
const key = String(args.key ?? '');
|
|
762
|
+
if (key.length === 0) {
|
|
763
|
+
return Object.keys(source.get('stone', {}));
|
|
764
|
+
}
|
|
765
|
+
return sanitize(source.get(key));
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
];
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Configuration for the `mcp` command.
|
|
773
|
+
*/
|
|
774
|
+
const mcpCommandOptions = {
|
|
775
|
+
name: 'mcp',
|
|
776
|
+
alias: 'm',
|
|
777
|
+
desc: 'Start an MCP server (stdio) exposing Stone.js knowledge + your app + your tools to a coding agent',
|
|
778
|
+
options: (yargs) => {
|
|
779
|
+
return yargs
|
|
780
|
+
.option('init', { type: 'boolean', desc: 'Register this server in .mcp.json (create/merge) and exit' })
|
|
781
|
+
.option('name', { type: 'string', desc: 'Override the MCP server name' })
|
|
782
|
+
.option('quiet', { type: 'boolean', desc: 'Silence the stderr activity log' });
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
/**
|
|
786
|
+
* Starts the MCP dev server from the `stone mcp` command.
|
|
787
|
+
*
|
|
788
|
+
* It reads `stone.mcpDev` from the blueprint (server name, instructions, your tools) and lets the
|
|
789
|
+
* MCP SDK own the protocol and tool execution: framework knowledge helpers do not need to traverse
|
|
790
|
+
* the kernel. `--name` / `--quiet` flags override the configured values.
|
|
791
|
+
*/
|
|
792
|
+
class McpCommand {
|
|
793
|
+
container;
|
|
794
|
+
/**
|
|
795
|
+
* @param container - The dependency injection container.
|
|
796
|
+
* @throws {McpDevError} If the container is not provided.
|
|
797
|
+
*/
|
|
798
|
+
constructor(container) {
|
|
799
|
+
this.container = container;
|
|
800
|
+
if (container === undefined) {
|
|
801
|
+
throw new McpDevError('Container is required to create a McpCommand instance.');
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Handle the `mcp` command: start the server and keep it running until interrupted.
|
|
806
|
+
*
|
|
807
|
+
* @param event - The incoming CLI event carrying the parsed flags.
|
|
808
|
+
*/
|
|
809
|
+
async handle(event) {
|
|
810
|
+
const blueprint = this.container.make('blueprint');
|
|
811
|
+
if (event.getMetadataValue('init', false)) {
|
|
812
|
+
const { file, changed } = initMcpJson(process.cwd());
|
|
813
|
+
process.stderr.write(changed ? `mcp: registered this server in ${file}\n` : `mcp: ${file} already registers this server\n`);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
const options = blueprint.get('stone.builder.mcpDev', {});
|
|
817
|
+
const name = event.getMetadataValue('name', options.name);
|
|
818
|
+
const quiet = event.getMetadataValue('quiet', options.quiet ?? false);
|
|
819
|
+
const tools = [...createIntrospectionTools(blueprint), ...(options.tools ?? [])];
|
|
820
|
+
await startMcpDevServer({ ...options, name, quiet, tools });
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Register the `mcp` command on the CLI.
|
|
826
|
+
*
|
|
827
|
+
* Contributed by the plugin rather than by the application, which is the whole point: introspection is
|
|
828
|
+
* a development concern, and an application should not have to declare a development tool to get one.
|
|
829
|
+
* The CLI runs plugin blueprint middleware in its own pipeline, and the CLI is itself a Stone.js app on
|
|
830
|
+
* the console platform, so the command lands exactly where every other command does.
|
|
831
|
+
*
|
|
832
|
+
* @param context - The blueprint context.
|
|
833
|
+
* @param next - The next middleware.
|
|
834
|
+
* @returns The blueprint.
|
|
835
|
+
*/
|
|
836
|
+
const SetMcpCommandsMiddleware = async (context, next) => {
|
|
837
|
+
if (context.blueprint.get('stone.adapter.platform') === NODE_CONSOLE_PLATFORM) {
|
|
838
|
+
context.blueprint.add('stone.adapter.commands', [{ options: mcpCommandOptions, isClass: true, module: McpCommand }]);
|
|
839
|
+
}
|
|
840
|
+
return await next(context);
|
|
841
|
+
};
|
|
842
|
+
/** The blueprint middleware this plugin contributes. */
|
|
843
|
+
const mcpDevPluginMiddleware = [
|
|
844
|
+
{ module: SetMcpCommandsMiddleware, priority: 5 }
|
|
845
|
+
];
|
|
846
|
+
/** Where the plugin writes the module it injects, relative to the build's `.stone/tmp` directory. */
|
|
847
|
+
const GENERATED_MODULE = 'plugins/mcp-dev-context.mjs';
|
|
848
|
+
/** The commands that mean "a developer is working right now", and nothing else. */
|
|
849
|
+
const DEV_COMMANDS = ['dev', 'serve', 'preview'];
|
|
850
|
+
/**
|
|
851
|
+
* The module injected into a development build.
|
|
852
|
+
*
|
|
853
|
+
* It contributes one lifecycle hook, and imports the publishing helper from the package rather than
|
|
854
|
+
* restating it, so the file format and the redaction rules keep living in one place.
|
|
855
|
+
*
|
|
856
|
+
* @returns The generated module's source.
|
|
857
|
+
*/
|
|
858
|
+
function generatedModule() {
|
|
859
|
+
return [
|
|
860
|
+
'// Generated by the @stone-js/mcp-dev CLI plugin, for development builds only.',
|
|
861
|
+
"import { publishAppContext } from '@stone-js/mcp-dev'",
|
|
862
|
+
'',
|
|
863
|
+
'export const mcpDevContextBlueprint = {',
|
|
864
|
+
' stone: {',
|
|
865
|
+
' lifecycleHooks: {',
|
|
866
|
+
' onStart: [({ blueprint }) => { try { publishAppContext(blueprint) } catch {} }]',
|
|
867
|
+
' }',
|
|
868
|
+
' }',
|
|
869
|
+
'}',
|
|
870
|
+
''
|
|
871
|
+
].join('\n');
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* The MCP dev CLI plugin.
|
|
875
|
+
*
|
|
876
|
+
* Introspection is a development concern, so it belongs to the build rather than to the application.
|
|
877
|
+
* Declaring it in the app would put a development tool in the application's own module graph — and
|
|
878
|
+
* make a production build depend on a package the application does not need — for a feature nobody
|
|
879
|
+
* uses in production. The build already knows when a developer is working, which is exactly when this
|
|
880
|
+
* is wanted, so the build is where it is decided.
|
|
881
|
+
*
|
|
882
|
+
* What it does, in one sentence: on a development build it injects a hook that makes the running
|
|
883
|
+
* application publish its resolved configuration, so `stone mcp` describes **that** application
|
|
884
|
+
* instead of the console boot it can reach by itself.
|
|
885
|
+
*
|
|
886
|
+
* Nothing is injected into a production build, and nothing has to be imported by the app.
|
|
887
|
+
*
|
|
888
|
+
* @returns The plugin.
|
|
889
|
+
*/
|
|
890
|
+
function mcpDevCliPlugin() {
|
|
891
|
+
return {
|
|
892
|
+
name: '@stone-js/mcp-dev',
|
|
893
|
+
description: 'Adds `stone mcp`, and lets it introspect the app you are actually running',
|
|
894
|
+
blueprintMiddleware: mcpDevPluginMiddleware,
|
|
895
|
+
onPrepare: async (context) => {
|
|
896
|
+
// Only while developing: a production artifact must carry none of this.
|
|
897
|
+
if (!DEV_COMMANDS.includes(context.command)) {
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
// The build decides presence, so the build is where turning it off belongs: an application that
|
|
901
|
+
// opts out gets nothing injected, rather than shipping code that decides not to run.
|
|
902
|
+
if (!context.blueprint.get('stone.builder.mcpDev.publishContext', true)) {
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
context.writeFile(GENERATED_MODULE, generatedModule());
|
|
906
|
+
context.addModule(`./${GENERATED_MODULE}`);
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* A ready-to-use plugin instance, used by first-party `package.json` auto-discovery.
|
|
912
|
+
*/
|
|
913
|
+
const plugin = mcpDevCliPlugin();
|
|
914
|
+
|
|
915
|
+
export { DEV_COMMANDS, GENERATED_MODULE, SetMcpCommandsMiddleware, plugin as default, generatedModule, mcpDevCliPlugin, mcpDevPluginMiddleware };
|