@adia-ai/mcp 0.8.37
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/CHANGELOG.md +1687 -0
- package/README.md +210 -0
- package/TOOLS.md +565 -0
- package/bin/adia-mcp +108 -0
- package/gen-ui/load-env.js +72 -0
- package/gen-ui/server.d.ts +20 -0
- package/gen-ui/server.js +378 -0
- package/gen-ui/session-sweep.js +56 -0
- package/gen-ui/tools/corpus.js +169 -0
- package/gen-ui/tools/discovery.js +89 -0
- package/gen-ui/tools/feedback.js +100 -0
- package/gen-ui/tools/ontology-context.js +45 -0
- package/gen-ui/tools/refine.js +158 -0
- package/gen-ui/tools/schema-to-zod.js +68 -0
- package/gen-ui/tools/synthesis.js +405 -0
- package/gen-ui/tools/validation.js +131 -0
- package/gen-ui/tools/zettel.js +87 -0
- package/package.json +58 -0
- package/protocol/server.d.ts +11 -0
- package/protocol/server.js +37 -0
- package/protocol/tools/protocol.js +106 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-local .env loader for the MCP server (gh#1186).
|
|
3
|
+
*
|
|
4
|
+
* The server used to side-effect-import the monorepo's own
|
|
5
|
+
* `scripts/load-env.mjs` by a path that walked three levels out of the
|
|
6
|
+
* package root. That path only exists inside this repo, so every
|
|
7
|
+
* standalone `npm install @adia-ai/gen-ui-mcp` died at boot with
|
|
8
|
+
* ERR_MODULE_NOT_FOUND before a single tool was registered.
|
|
9
|
+
*
|
|
10
|
+
* This copy ships IN the package and searches upward from the working
|
|
11
|
+
* directory instead of from a fixed repo layout — which finds the repo
|
|
12
|
+
* root `.env` when run from anywhere in this monorepo, and finds the
|
|
13
|
+
* consumer's project `.env` when installed from npm.
|
|
14
|
+
*
|
|
15
|
+
* The walk STOPS at the project root — the first directory holding a
|
|
16
|
+
* `package.json` or `.git`. An unbounded walk is not merely untidy: from a
|
|
17
|
+
* git worktree under `.claude/worktrees/` it climbed out into the PARENT
|
|
18
|
+
* checkout's `.env`, handed the server live API keys a keyless test run was
|
|
19
|
+
* supposed to prove it could do without, and turned `smoke-6-plan-app-state`
|
|
20
|
+
* into a real 32k-token call that blew the MCP client's 60s timeout. A
|
|
21
|
+
* loader must never read credentials from outside the project it belongs to.
|
|
22
|
+
*
|
|
23
|
+
* Existing env vars always win; a missing .env is silent (keys are
|
|
24
|
+
* expected from the environment, which is how MCP clients pass them).
|
|
25
|
+
* Skipped entirely when ADIA_SKIP_DOTENV is set — selftests that must
|
|
26
|
+
* prove keyless behaviour on a machine that does have a .env (gh#804).
|
|
27
|
+
* Note that the MCP SDK's stdio transport passes a FILTERED environment to
|
|
28
|
+
* the servers it spawns, so that opt-out does not survive a client spawn;
|
|
29
|
+
* the project-root bound is what actually keeps a test run keyless.
|
|
30
|
+
*
|
|
31
|
+
* Usage: import './load-env.js'; // side-effect import, first line
|
|
32
|
+
*/
|
|
33
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
34
|
+
import { resolve, dirname, join } from "node:path";
|
|
35
|
+
const isProjectRoot = (dir) => existsSync(join(dir, "package.json")) || existsSync(join(dir, ".git"));
|
|
36
|
+
function findEnvFile(startDir) {
|
|
37
|
+
let dir = startDir;
|
|
38
|
+
for (;;) {
|
|
39
|
+
const candidate = resolve(dir, ".env");
|
|
40
|
+
if (existsSync(candidate))
|
|
41
|
+
return candidate;
|
|
42
|
+
if (isProjectRoot(dir))
|
|
43
|
+
return null; // project boundary — never climb past it
|
|
44
|
+
const parent = dirname(dir);
|
|
45
|
+
if (parent === dir)
|
|
46
|
+
return null;
|
|
47
|
+
dir = parent;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (!process.env["ADIA_SKIP_DOTENV"]) {
|
|
51
|
+
try {
|
|
52
|
+
const envPath = findEnvFile(process.cwd());
|
|
53
|
+
if (envPath) {
|
|
54
|
+
for (const line of readFileSync(envPath, "utf8").split("\n")) {
|
|
55
|
+
const trimmed = line.trim();
|
|
56
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
57
|
+
continue;
|
|
58
|
+
const eqIdx = trimmed.indexOf("=");
|
|
59
|
+
if (eqIdx < 0)
|
|
60
|
+
continue;
|
|
61
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
62
|
+
const val = trimmed.slice(eqIdx + 1).trim();
|
|
63
|
+
if (!process.env[key])
|
|
64
|
+
process.env[key] = val;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// unreadable .env — expect keys from the environment
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds the AdiaUI generation MCP server (30 tools: generation, discovery,
|
|
5
|
+
* retrieval, synthesis, validation, feedback/eval) with no transport
|
|
6
|
+
* attached. Exported for tests and for `scripts/build/generate-mcp-tools-md.mjs`;
|
|
7
|
+
* this file's own top-level `main()` call (unconditional, at module scope —
|
|
8
|
+
* unlike the protocol server's guarded entry point) is what actually starts a
|
|
9
|
+
* transport on `node server.js`.
|
|
10
|
+
*/
|
|
11
|
+
export function createServer(): McpServer;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolves the LLM adapter to use for the given server (or the single
|
|
15
|
+
* sampling-capable server, if none is passed): the connected client's MCP
|
|
16
|
+
* sampling capability when available (no separate API key needed), else the
|
|
17
|
+
* `.env`-configured provider adapter. Returns `null` when neither is
|
|
18
|
+
* available.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveAdapter(forServer?: McpServer): Promise<unknown | null>;
|
package/gen-ui/server.js
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "./load-env.js";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
6
|
+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
7
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
+
import { idleTtlMs, startIdleSweep } from "./session-sweep.js";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { generateUI } from "@adia-ai/gen-ui/compose/core";
|
|
12
|
+
import { projectEngineResult } from "@adia-ai/gen-ui/compose/strategies/registry";
|
|
13
|
+
import {
|
|
14
|
+
getCatalog,
|
|
15
|
+
getFullCatalog,
|
|
16
|
+
getTraits
|
|
17
|
+
} from "@adia-ai/gen-ui/retrieval/catalog";
|
|
18
|
+
import { serializeEntry } from "@adia-ai/gen-ui/retrieval/component-entry";
|
|
19
|
+
import { classifyIntent, getDomain, getAllDomains } from "@adia-ai/gen-ui/retrieval/domain-router";
|
|
20
|
+
import { getAntiPatterns } from "@adia-ai/gen-ui/retrieval/anti-patterns";
|
|
21
|
+
import {
|
|
22
|
+
loadAll as loadZettelCorpus,
|
|
23
|
+
getAllCompositions as getAllZettelCompositions
|
|
24
|
+
} from "@adia-ai/gen-ui/compose/strategies/zettel/composition-library";
|
|
25
|
+
const _zettelBoot = await loadZettelCorpus();
|
|
26
|
+
console.error(
|
|
27
|
+
`[gen-ui-mcp] zettel corpus: ${_zettelBoot.compositionCount} compositions`
|
|
28
|
+
);
|
|
29
|
+
import { getChunkIndex } from "@adia-ai/gen-ui/corpus/chunk-library";
|
|
30
|
+
const _chunkIndex = getChunkIndex();
|
|
31
|
+
if (_chunkIndex) {
|
|
32
|
+
const idx = _chunkIndex;
|
|
33
|
+
const byKind = idx["by_kind"] ?? {};
|
|
34
|
+
console.error(
|
|
35
|
+
`[gen-ui-mcp] gen-ui chunks: ${idx["unique_names"]} unique chunks (${idx["total_instances"]} instances; block=${byKind["block"] ?? 0}, panel=${byKind["panel"] ?? 0}, page=${byKind["page"] ?? 0})`
|
|
36
|
+
);
|
|
37
|
+
} else {
|
|
38
|
+
console.error("[gen-ui-mcp] gen-ui chunks: index not found \u2014 run `npm run harvest:chunks`");
|
|
39
|
+
}
|
|
40
|
+
import { registerSynthesisTools } from "./tools/synthesis.js";
|
|
41
|
+
import { registerValidationTools } from "./tools/validation.js";
|
|
42
|
+
import { registerFeedbackTools } from "./tools/feedback.js";
|
|
43
|
+
import { registerCorpusTools } from "./tools/corpus.js";
|
|
44
|
+
import { registerZettelTools } from "./tools/zettel.js";
|
|
45
|
+
import { registerDiscoveryTools } from "./tools/discovery.js";
|
|
46
|
+
import { registerRefineTools } from "./tools/refine.js";
|
|
47
|
+
import { ontologyContextSchema, ONTOLOGY_OUTPUT_SCHEMA_PROMPT } from "./tools/ontology-context.js";
|
|
48
|
+
function createServer() {
|
|
49
|
+
const server = new McpServer({
|
|
50
|
+
name: "adia-ui",
|
|
51
|
+
version: "0.1.0",
|
|
52
|
+
capabilities: {
|
|
53
|
+
sampling: {}
|
|
54
|
+
// allows server to request LLM inference from the host (IDE/client)
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
registerAllTools(server);
|
|
58
|
+
return server;
|
|
59
|
+
}
|
|
60
|
+
let samplingServer = null;
|
|
61
|
+
async function resolveAdapter(forServer) {
|
|
62
|
+
const active = forServer ?? samplingServer;
|
|
63
|
+
const hasSampling = active && active.server?._clientCapabilities?.sampling;
|
|
64
|
+
if (hasSampling) {
|
|
65
|
+
return {
|
|
66
|
+
async complete({ messages, systemPrompt }) {
|
|
67
|
+
const result = await active.server.createMessage({
|
|
68
|
+
messages: messages.map((m) => ({
|
|
69
|
+
role: m.role,
|
|
70
|
+
content: { type: "text", text: m.content }
|
|
71
|
+
})),
|
|
72
|
+
...systemPrompt ? { systemPrompt } : {},
|
|
73
|
+
maxTokens: 32768
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
content: typeof result.content === "string" ? result.content : result.content?.text ?? "",
|
|
77
|
+
stopReason: result.stopReason ?? "end",
|
|
78
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 }
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const { createAdapter } = await import("@adia-ai/llm/bridge");
|
|
84
|
+
return createAdapter();
|
|
85
|
+
}
|
|
86
|
+
function registerAllTools(server) {
|
|
87
|
+
server.tool(
|
|
88
|
+
"plan_app_state",
|
|
89
|
+
`Analyze a natural language prompt and extract the top-level Generative UI Ontology structures (Intent, Domain, Tasks, Experience).
|
|
90
|
+
|
|
91
|
+
Use this tool BEFORE generating UI to ensure you have walked the Reasoning Ladder and properly modeled the nouns and verbs of the feature. This bounds hallucination and forces a focus on tasks over raw layouts.
|
|
92
|
+
|
|
93
|
+
Honesty clause (REQ-03, gh#1208): this tool mechanizes only the Reasoning Ladder's ~Tier 0/1 rungs \u2014 one prompt, one LLM pass, no plan a reviewer can gate. It is the agent operator's (P4's) only ladder surface \u2014 a persona that cannot preload skills \u2014 not a substitute for the full rungs 0-19 walk. For anything past Tier 0/1 (roles, decisions, a scored wireframe checkpoint), use a Domain Plan from app-planning-agent's preloaded ladder skill instead.
|
|
94
|
+
|
|
95
|
+
Output shape \u2014 the four ontology blocks generate_ui's context param accepts (ontologyContextSchema, tools/ontology-context.ts): intent (user_goal, product_goal), domain (entities[], metrics[]), tasks (primary[], inspection[]), experience (mode: workspace|dashboard|wizard|chat, shell: admin|chat|editor|simple|embed|none \u2014 matches the Orientation Record's own Shell axis). The extracted output is validated against this same schema before being returned (gh#1208 review finding 2): a malformed shell/mode value (or any other schema violation) fails loudly with a typed error instead of passing the raw LLM text through.`,
|
|
96
|
+
{
|
|
97
|
+
prompt: z.string().describe('The natural language request (e.g., "Build a dashboard for incoming sales leads")')
|
|
98
|
+
},
|
|
99
|
+
async ({ prompt }) => {
|
|
100
|
+
const llm = await resolveAdapter(server);
|
|
101
|
+
const systemPrompt = `You are the A2UI Ontology Planner.
|
|
102
|
+
Given a user prompt, you must extract the Core App State using the 5-Gate Reasoning Ladder.
|
|
103
|
+
|
|
104
|
+
Output ONLY a JSON object matching this schema, nothing else:
|
|
105
|
+
${ONTOLOGY_OUTPUT_SCHEMA_PROMPT}`;
|
|
106
|
+
try {
|
|
107
|
+
const response = await llm.complete({
|
|
108
|
+
messages: [{ role: "user", content: prompt }],
|
|
109
|
+
system: systemPrompt,
|
|
110
|
+
temperature: 0.2
|
|
111
|
+
});
|
|
112
|
+
const jsonMatch = response.content.match(/\{[\s\S]*\}/);
|
|
113
|
+
if (!jsonMatch) {
|
|
114
|
+
throw new Error("LLM failed to output valid JSON for the ontology plan.");
|
|
115
|
+
}
|
|
116
|
+
const rawPlan = JSON.parse(jsonMatch[0]);
|
|
117
|
+
const validated = ontologyContextSchema.safeParse(rawPlan);
|
|
118
|
+
if (!validated.success) {
|
|
119
|
+
return {
|
|
120
|
+
content: [
|
|
121
|
+
{
|
|
122
|
+
type: "text",
|
|
123
|
+
text: `plan_app_state: extracted plan failed ontology schema validation \u2014 ${validated.error.message}`
|
|
124
|
+
}
|
|
125
|
+
],
|
|
126
|
+
isError: true
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
content: [
|
|
131
|
+
{
|
|
132
|
+
type: "text",
|
|
133
|
+
text: JSON.stringify(validated.data, null, 2)
|
|
134
|
+
}
|
|
135
|
+
]
|
|
136
|
+
};
|
|
137
|
+
} catch (e) {
|
|
138
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
139
|
+
return {
|
|
140
|
+
content: [
|
|
141
|
+
{ type: "text", text: `Failed to plan app state: ${err.message}` }
|
|
142
|
+
],
|
|
143
|
+
isError: true
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
server.tool(
|
|
149
|
+
"generate_ui",
|
|
150
|
+
`Generate A2UI components from a natural language description.
|
|
151
|
+
|
|
152
|
+
Engine selection:
|
|
153
|
+
- "monolithic" (default) \u2014 pattern-match + adapt. Searches a corpus of 96+ pre-authored monolithic templates and adapts the best match. Battle-tested; highest F1 on held-out intents.
|
|
154
|
+
- "zettel" \u2014 fragment-graph composer. Composes UI from named atomic fragments (labeled-input, card-header-with-description, etc.) with a precomputed backlink graph. Higher reusability; supports composition-iterated refinement on multi-turn edits.
|
|
155
|
+
|
|
156
|
+
Mode selection (monolithic only; zettel uses "instant"):
|
|
157
|
+
- "pro" (default) \u2014 LLM-powered generation with pattern adaptation.
|
|
158
|
+
- "thinking" \u2014 Full LLM-powered generation with semantic search, decomposition, and streaming.
|
|
159
|
+
- "instant" \u2014 fast pattern matching, no LLM.
|
|
160
|
+
|
|
161
|
+
The generator knows 96+ UI patterns across 5 domains: forms, data, layout, agent, navigation.`,
|
|
162
|
+
{
|
|
163
|
+
intent: z.string().describe("Description of the UI to generate"),
|
|
164
|
+
engine: z.enum(["monolithic", "zettel"]).optional().describe('Generation engine. "monolithic" (default) is pattern-match + adapt. "zettel" is fragment-graph composition.'),
|
|
165
|
+
mode: z.enum(["instant", "pro", "thinking"]).optional().describe('Generation mode (monolithic). "pro" (default) uses LLM with pattern adaptation. "thinking" uses full LLM generation. "instant" uses fast pattern matching.'),
|
|
166
|
+
sessionId: z.string().optional().describe("Opaque session identifier for multi-turn iteration (zettel only). When provided, follow-up calls with the same sessionId modify the prior turn's canvas instead of regenerating from scratch. Omit for stateless generation."),
|
|
167
|
+
context: ontologyContextSchema.optional().describe(`Ontology context parsed by plan_app_state \u2014 intent, domain, tasks, experience (REQ-03, gh#1208: all four blocks are accepted and validated here; previously only domain/tasks survived the schema, intent/experience were silently dropped). Per-engine caveat: only the "monolithic" engine's system-prompt injection reads it today, and only five of the schema's leaves (domain.entities, domain.metrics, tasks.primary, experience.mode, experience.shell) \u2014 intent.* and tasks.inspection validate but are not yet interpolated into the prompt. The "zettel" engine (and the free-form tier reachable via engine escalation) ignores context entirely; passing it has no effect there.`)
|
|
168
|
+
},
|
|
169
|
+
async ({ intent, engine, mode, sessionId, context }) => {
|
|
170
|
+
try {
|
|
171
|
+
const selectedEngine = engine ?? "monolithic";
|
|
172
|
+
const effectiveMode = selectedEngine === "zettel" ? "instant" : mode ?? "pro";
|
|
173
|
+
const llmAdapter = effectiveMode !== "instant" ? await resolveAdapter(server) : void 0;
|
|
174
|
+
const result = await generateUI({
|
|
175
|
+
intent,
|
|
176
|
+
engine: selectedEngine,
|
|
177
|
+
mode: effectiveMode,
|
|
178
|
+
sessionId,
|
|
179
|
+
context,
|
|
180
|
+
// Pass the ontology context down to the composer
|
|
181
|
+
...llmAdapter ? { llmAdapter } : {}
|
|
182
|
+
});
|
|
183
|
+
return {
|
|
184
|
+
content: [{
|
|
185
|
+
type: "text",
|
|
186
|
+
text: JSON.stringify(projectEngineResult(result, selectedEngine), null, 2)
|
|
187
|
+
}]
|
|
188
|
+
};
|
|
189
|
+
} catch (err) {
|
|
190
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
191
|
+
return { content: [{ type: "text", text: `Generation error: ${e.message}` }], isError: true };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
);
|
|
195
|
+
server.tool(
|
|
196
|
+
"classify_intent",
|
|
197
|
+
"Classify intent into a UI domain.",
|
|
198
|
+
{
|
|
199
|
+
text: z.string().describe("Intent text")
|
|
200
|
+
},
|
|
201
|
+
async ({ text }) => {
|
|
202
|
+
return { content: [{ type: "text", text: JSON.stringify(classifyIntent(text), null, 2) }] };
|
|
203
|
+
}
|
|
204
|
+
);
|
|
205
|
+
server.tool(
|
|
206
|
+
"run_eval",
|
|
207
|
+
"Run the offline eval harness against the held-out intent set. Returns aggregate scores and per-intent results.",
|
|
208
|
+
{
|
|
209
|
+
domain: z.string().optional().describe("Filter by domain (forms, data, layout, agent, navigation)"),
|
|
210
|
+
limit: z.number().optional().describe("Max intents to evaluate")
|
|
211
|
+
},
|
|
212
|
+
async ({ domain, limit }) => {
|
|
213
|
+
try {
|
|
214
|
+
const { runHarness } = await import("@adia-ai/gen-ui/compose/evals");
|
|
215
|
+
const summary = await runHarness({
|
|
216
|
+
generate: (args) => generateUI({ intent: args["intent"], mode: "instant" }),
|
|
217
|
+
domain,
|
|
218
|
+
limit,
|
|
219
|
+
mode: "instant"
|
|
220
|
+
});
|
|
221
|
+
return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
|
|
222
|
+
} catch (err) {
|
|
223
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
224
|
+
return { content: [{ type: "text", text: `Eval error: ${e.message}` }], isError: true };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
);
|
|
228
|
+
server.resource(
|
|
229
|
+
"catalog",
|
|
230
|
+
"a2ui://catalog/manifest",
|
|
231
|
+
async (uri) => {
|
|
232
|
+
const catalog = await getFullCatalog();
|
|
233
|
+
const serializable = {
|
|
234
|
+
version: catalog.version,
|
|
235
|
+
totalTypes: catalog.totalTypes,
|
|
236
|
+
totalTraits: catalog.totalTraits,
|
|
237
|
+
components: [...catalog.entries.values()].map((e) => serializeEntry(e, "summary")),
|
|
238
|
+
traits: catalog.traits
|
|
239
|
+
};
|
|
240
|
+
return {
|
|
241
|
+
contents: [{
|
|
242
|
+
uri: uri.href,
|
|
243
|
+
mimeType: "application/json",
|
|
244
|
+
text: JSON.stringify(serializable, null, 2)
|
|
245
|
+
}]
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
);
|
|
249
|
+
server.resource(
|
|
250
|
+
"compositions",
|
|
251
|
+
"a2ui://catalog/compositions",
|
|
252
|
+
async (uri) => ({
|
|
253
|
+
contents: [{
|
|
254
|
+
uri: uri.href,
|
|
255
|
+
mimeType: "application/json",
|
|
256
|
+
text: JSON.stringify(getAllZettelCompositions(), null, 2)
|
|
257
|
+
}]
|
|
258
|
+
})
|
|
259
|
+
);
|
|
260
|
+
server.resource(
|
|
261
|
+
"anti-patterns",
|
|
262
|
+
"a2ui://catalog/anti-patterns",
|
|
263
|
+
async (uri) => ({
|
|
264
|
+
contents: [{
|
|
265
|
+
uri: uri.href,
|
|
266
|
+
mimeType: "application/json",
|
|
267
|
+
text: JSON.stringify(getAntiPatterns(), null, 2)
|
|
268
|
+
}]
|
|
269
|
+
})
|
|
270
|
+
);
|
|
271
|
+
server.resource(
|
|
272
|
+
"domains",
|
|
273
|
+
"a2ui://catalog/domains",
|
|
274
|
+
async (uri) => {
|
|
275
|
+
const domains = getAllDomains().map((name) => ({
|
|
276
|
+
name,
|
|
277
|
+
...getDomain(name)
|
|
278
|
+
}));
|
|
279
|
+
return {
|
|
280
|
+
contents: [{
|
|
281
|
+
uri: uri.href,
|
|
282
|
+
mimeType: "application/json",
|
|
283
|
+
text: JSON.stringify(domains, null, 2)
|
|
284
|
+
}]
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
);
|
|
288
|
+
registerValidationTools(server);
|
|
289
|
+
registerFeedbackTools(server);
|
|
290
|
+
registerCorpusTools(server);
|
|
291
|
+
registerZettelTools(server);
|
|
292
|
+
registerSynthesisTools(server);
|
|
293
|
+
registerDiscoveryTools(server);
|
|
294
|
+
registerRefineTools(server);
|
|
295
|
+
}
|
|
296
|
+
async function startStdio() {
|
|
297
|
+
const transport = new StdioServerTransport();
|
|
298
|
+
const server = createServer();
|
|
299
|
+
samplingServer = server;
|
|
300
|
+
await server.connect(transport);
|
|
301
|
+
const catalog = await getCatalog();
|
|
302
|
+
const traits = getTraits();
|
|
303
|
+
console.error(`[gen-ui-mcp] stdio transport ready (${catalog.totalTypes} components, ${traits.length} traits, ${_zettelBoot.compositionCount} compositions)`);
|
|
304
|
+
}
|
|
305
|
+
function sendJsonRpcError(res, status, code, message) {
|
|
306
|
+
if (res.headersSent) return;
|
|
307
|
+
res.status(status).json({ jsonrpc: "2.0", error: { code, message }, id: null });
|
|
308
|
+
}
|
|
309
|
+
async function startHttp(port) {
|
|
310
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
311
|
+
const ttlMs = idleTtlMs();
|
|
312
|
+
startIdleSweep(sessions, ttlMs);
|
|
313
|
+
const app = createMcpExpressApp({ host: "0.0.0.0" });
|
|
314
|
+
app.all("/mcp", async (req, res) => {
|
|
315
|
+
try {
|
|
316
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
317
|
+
const existing = sessionId ? sessions.get(sessionId) : void 0;
|
|
318
|
+
if (existing) {
|
|
319
|
+
existing.lastActivity = Date.now();
|
|
320
|
+
await existing.transport.handleRequest(req, res, req.body);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (req.method === "POST" && isInitializeRequest(req.body)) {
|
|
324
|
+
const id = randomUUID();
|
|
325
|
+
const sessionServer = createServer();
|
|
326
|
+
const transport = new StreamableHTTPServerTransport({
|
|
327
|
+
sessionIdGenerator: () => id,
|
|
328
|
+
onsessioninitialized: (sid) => sessions.set(sid, {
|
|
329
|
+
transport,
|
|
330
|
+
server: sessionServer,
|
|
331
|
+
lastActivity: Date.now()
|
|
332
|
+
})
|
|
333
|
+
});
|
|
334
|
+
let torndown = false;
|
|
335
|
+
transport.onclose = () => {
|
|
336
|
+
if (torndown) return;
|
|
337
|
+
torndown = true;
|
|
338
|
+
sessions.delete(id);
|
|
339
|
+
void sessionServer.close().catch(() => {
|
|
340
|
+
});
|
|
341
|
+
};
|
|
342
|
+
await sessionServer.connect(transport);
|
|
343
|
+
await transport.handleRequest(req, res, req.body);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (sessionId) {
|
|
347
|
+
sendJsonRpcError(res, 404, -32001, `Unknown or expired session '${sessionId}' \u2014 send an initialize request to start a new one`);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
sendJsonRpcError(res, 400, -32600, "Invalid request \u2014 send mcp-session-id, or POST an initialize request");
|
|
351
|
+
} catch (err) {
|
|
352
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
353
|
+
console.error(`[gen-ui-mcp] HTTP request failed: ${e.stack ?? e.message}`);
|
|
354
|
+
sendJsonRpcError(res, 500, -32603, `Internal server error: ${e.message}`);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
app.listen(port, () => {
|
|
358
|
+
const catalog = getCatalog();
|
|
359
|
+
console.error(`[gen-ui-mcp] HTTP transport ready on http://0.0.0.0:${port}/mcp`);
|
|
360
|
+
console.error(
|
|
361
|
+
ttlMs > 0 ? `[gen-ui-mcp] session idle TTL: ${ttlMs}ms (MCP_SESSION_TTL_MS)` : `[gen-ui-mcp] session idle TTL: disabled (MCP_SESSION_TTL_MS<=0)`
|
|
362
|
+
);
|
|
363
|
+
console.error(`[gen-ui-mcp] API keys required for pro/thinking mode (no sampling in HTTP mode)`);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
async function main() {
|
|
367
|
+
const httpPort = typeof process !== "undefined" && process.env?.MCP_HTTP_PORT ? parseInt(process.env.MCP_HTTP_PORT, 10) : null;
|
|
368
|
+
if (httpPort) {
|
|
369
|
+
await startHttp(httpPort);
|
|
370
|
+
} else {
|
|
371
|
+
await startStdio();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
main().catch(console.error);
|
|
375
|
+
export {
|
|
376
|
+
createServer,
|
|
377
|
+
resolveAdapter
|
|
378
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const DEFAULT_IDLE_TTL_MS = 30 * 6e4;
|
|
2
|
+
const MIN_SWEEP_MS = 250;
|
|
3
|
+
const MAX_SWEEP_MS = 6e4;
|
|
4
|
+
function idleTtlMs(env = typeof process !== "undefined" ? process.env : void 0) {
|
|
5
|
+
const raw = env?.["MCP_SESSION_TTL_MS"];
|
|
6
|
+
if (raw === void 0 || raw === "") return DEFAULT_IDLE_TTL_MS;
|
|
7
|
+
const parsed = Number(raw);
|
|
8
|
+
if (!Number.isFinite(parsed)) {
|
|
9
|
+
console.error(
|
|
10
|
+
`[gen-ui-mcp] MCP_SESSION_TTL_MS='${raw}' is not a number \u2014 using ${DEFAULT_IDLE_TTL_MS}ms`
|
|
11
|
+
);
|
|
12
|
+
return DEFAULT_IDLE_TTL_MS;
|
|
13
|
+
}
|
|
14
|
+
return parsed <= 0 ? 0 : parsed;
|
|
15
|
+
}
|
|
16
|
+
function sweepIntervalMs(ttlMs) {
|
|
17
|
+
return Math.min(MAX_SWEEP_MS, Math.max(MIN_SWEEP_MS, Math.floor(ttlMs / 4)));
|
|
18
|
+
}
|
|
19
|
+
function evictIdleSessions(sessions, ttlMs, now = Date.now()) {
|
|
20
|
+
if (ttlMs <= 0) return [];
|
|
21
|
+
const evicted = [];
|
|
22
|
+
for (const [id, session] of [...sessions]) {
|
|
23
|
+
if (now - session.lastActivity < ttlMs) continue;
|
|
24
|
+
sessions.delete(id);
|
|
25
|
+
evicted.push(id);
|
|
26
|
+
try {
|
|
27
|
+
const closing = session.transport.close();
|
|
28
|
+
if (closing && typeof closing.catch === "function") {
|
|
29
|
+
void closing.catch(() => {
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return evicted;
|
|
36
|
+
}
|
|
37
|
+
function startIdleSweep(sessions, ttlMs) {
|
|
38
|
+
if (ttlMs <= 0) {
|
|
39
|
+
console.error("[gen-ui-mcp] session idle eviction disabled (MCP_SESSION_TTL_MS<=0)");
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const timer = setInterval(() => {
|
|
43
|
+
for (const id of evictIdleSessions(sessions, ttlMs)) {
|
|
44
|
+
console.error(`[gen-ui-mcp] session ${id} evicted after ${ttlMs}ms idle`);
|
|
45
|
+
}
|
|
46
|
+
}, sweepIntervalMs(ttlMs));
|
|
47
|
+
timer.unref?.();
|
|
48
|
+
return timer;
|
|
49
|
+
}
|
|
50
|
+
export {
|
|
51
|
+
DEFAULT_IDLE_TTL_MS,
|
|
52
|
+
evictIdleSessions,
|
|
53
|
+
idleTtlMs,
|
|
54
|
+
startIdleSweep,
|
|
55
|
+
sweepIntervalMs
|
|
56
|
+
};
|