Skip to content

Commit e5e850a

Browse files
committed
add Phase 8.8 generator --mcp scaffold emission
`fhir-gen generate --mcp <dir>` (or GeneratorOptions.mcpOutDir programmatically) writes an MCP server scaffold alongside the typed client output: mcp.config.json seeded with the bound IG's resource types and safe defaults (writes=[], confirmWrites=true, defaultSearchCount=20, maxResponseBytes=65536), a server.ts shim that loads the config and reads FHIR_BASE_URL/FHIR_TOKEN at runtime, and a README pointing at the launch incantation. Together with --ig, one fhir-gen generate command can now emit both the typed FHIR client and a ready-to-launch MCP server for the same upstream + IG.
1 parent acae129 commit e5e850a

3 files changed

Lines changed: 174 additions & 0 deletions

File tree

packages/cli/src/commands/generate.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const generateCommand = new Command("generate")
1515
.option("--include-spec", "Emit markdown spec files alongside types for AI/LLM context")
1616
.option("--validator <target>", "Emit Standard Schema validators using: zod | native")
1717
.option("--strict-extensible", "Treat extensible bindings as closed enums in validators")
18+
.option("--mcp <dir>", "Emit an MCP server scaffold (server.ts + mcp.config.json) into this directory")
1819
.action(async (opts) => {
1920
const resources = opts.resources ? opts.resources.split(",").map((r: string) => r.trim()) : undefined;
2021

@@ -35,5 +36,6 @@ export const generateCommand = new Command("generate")
3536
includeSpec: opts.includeSpec,
3637
validator,
3738
strictExtensible: opts.strictExtensible,
39+
mcpOutDir: opts.mcp ? resolve(opts.mcp) : undefined,
3840
});
3941
});

packages/generator/src/generator.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,14 @@ export interface GeneratorOptions {
8080
validator?: ValidatorTarget | undefined;
8181
/** Treat extensible bindings as closed enums in validators (default: open). */
8282
strictExtensible?: boolean | undefined;
83+
/**
84+
* Phase 8.8 — when set, emit an MCP server scaffold under this directory
85+
* containing `mcp.config.json` (resource list, IG reference) and a
86+
* `server.ts` shim that calls `@fhir-dsl/mcp`'s `createServer`. The
87+
* scaffold reads the upstream base URL from the FHIR_BASE_URL env var
88+
* at runtime so the generated artefacts stay portable.
89+
*/
90+
mcpOutDir?: string | undefined;
8391
}
8492

8593
export async function generate(options: GeneratorOptions): Promise<void> {
@@ -427,6 +435,16 @@ export async function generate(options: GeneratorOptions): Promise<void> {
427435
}
428436
}
429437

438+
// --- Phase 8.8: MCP server scaffold ---
439+
if (options.mcpOutDir) {
440+
await emitMcpScaffold({
441+
outDir: options.mcpOutDir,
442+
version,
443+
igRefs: igPackages,
444+
resourceTypes: resourceModels.map((r) => r.name),
445+
});
446+
}
447+
430448
// --- Spec index ---
431449
if (options.includeSpec) {
432450
const specDir = join(versionDir, "spec");
@@ -483,3 +501,96 @@ async function fileExists(path: string): Promise<boolean> {
483501
return false;
484502
}
485503
}
504+
505+
interface McpScaffoldOptions {
506+
outDir: string;
507+
version: string;
508+
igRefs: readonly string[];
509+
resourceTypes: readonly string[];
510+
}
511+
512+
async function emitMcpScaffold(opts: McpScaffoldOptions): Promise<void> {
513+
await mkdir(opts.outDir, { recursive: true });
514+
515+
const config = {
516+
version: opts.version,
517+
igRefs: [...opts.igRefs],
518+
resourceTypes: [...opts.resourceTypes].sort(),
519+
writes: [] as string[],
520+
confirmWrites: true,
521+
dryRun: false,
522+
defaultSearchCount: 20,
523+
maxResponseBytes: 65536,
524+
};
525+
await writeFile(join(opts.outDir, "mcp.config.json"), `${JSON.stringify(config, null, 2)}\n`, "utf-8");
526+
527+
const serverContents = `import { readFileSync } from "node:fs";
528+
import { fileURLToPath } from "node:url";
529+
import { dirname, resolve } from "node:path";
530+
import { createServer, JsonLogAuditSink, stdioTransport } from "@fhir-dsl/mcp";
531+
532+
const __dirname = dirname(fileURLToPath(import.meta.url));
533+
const config = JSON.parse(readFileSync(resolve(__dirname, "mcp.config.json"), "utf-8"));
534+
535+
const baseUrl = process.env.FHIR_BASE_URL;
536+
if (!baseUrl) {
537+
console.error("Set FHIR_BASE_URL to the upstream FHIR endpoint before launching the MCP server.");
538+
process.exit(1);
539+
}
540+
541+
const token = process.env.FHIR_TOKEN;
542+
543+
const server = createServer({
544+
name: "${opts.igRefs[0]?.replace(/[@/]/g, "-") ?? `fhir-${opts.version}`}-mcp",
545+
version: "0.1.0",
546+
baseUrl,
547+
resourceTypes: config.resourceTypes,
548+
audit: new JsonLogAuditSink(),
549+
...(config.writes && config.writes.length > 0 ? { writes: config.writes } : {}),
550+
...(config.confirmWrites ? { confirmWrites: true } : {}),
551+
...(config.dryRun ? { dryRun: true } : {}),
552+
...(config.defaultSearchCount !== undefined ? { defaultSearchCount: config.defaultSearchCount } : {}),
553+
...(config.maxResponseBytes !== undefined ? { maxResponseBytes: config.maxResponseBytes } : {}),
554+
...(token ? { auth: { kind: "bearer" as const, token } } : {}),
555+
});
556+
557+
console.error(\`[fhir-dsl-mcp] starting stdio server for \${baseUrl} (\${config.resourceTypes.length} resource type(s))\`);
558+
await server.listen(stdioTransport());
559+
`;
560+
await writeFile(join(opts.outDir, "server.ts"), serverContents, "utf-8");
561+
562+
const readme = `# Generated FHIR MCP server
563+
564+
This directory was emitted by \`fhir-gen generate --mcp <out>\` and is
565+
ready to launch as an MCP stdio server.
566+
567+
## Run
568+
569+
\`\`\`sh
570+
FHIR_BASE_URL=https://your-fhir-server/baseR${opts.version.replace(/^r/i, "").toUpperCase()} \\
571+
FHIR_TOKEN=optional-bearer-token \\
572+
node --experimental-strip-types server.ts
573+
\`\`\`
574+
575+
(or compile \`server.ts\` to JS first if your runtime doesn't support
576+
type stripping).
577+
578+
## Configuration
579+
580+
\`mcp.config.json\` controls the resource surface, write gating, and
581+
token-economy defaults. Edit it to:
582+
583+
- Add a \`writes\` whitelist (e.g. \`["create", "update"]\`) to expose
584+
write tools.
585+
- Tighten or relax \`maxResponseBytes\` and \`defaultSearchCount\`.
586+
- Disable \`confirmWrites\` if your agent doesn't need explicit
587+
confirmation on every write.
588+
589+
## IGs bound
590+
591+
${opts.igRefs.length > 0 ? opts.igRefs.map((r) => `- \`${r}\``).join("\n") : "_(no IGs — base FHIR ${opts.version})_"}
592+
`;
593+
await writeFile(join(opts.outDir, "README.md"), readme, "utf-8");
594+
595+
console.info(`Generated MCP scaffold in ${opts.outDir}`);
596+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
5+
import { generate } from "../../src/generator.js";
6+
7+
const FIXTURES_DIR = join(__dirname, "..", "fixtures", "terminology");
8+
9+
describe("generate --mcp <out> scaffold", () => {
10+
let outDir: string;
11+
let mcpDir: string;
12+
13+
beforeAll(async () => {
14+
outDir = mkdtempSync(join(tmpdir(), "fhir-dsl-mcp-out-"));
15+
mcpDir = mkdtempSync(join(tmpdir(), "fhir-dsl-mcp-scaffold-"));
16+
await generate({
17+
version: "r4",
18+
outDir,
19+
localSpecDir: FIXTURES_DIR,
20+
mcpOutDir: mcpDir,
21+
});
22+
});
23+
24+
afterAll(() => {
25+
if (outDir) rmSync(outDir, { recursive: true, force: true });
26+
if (mcpDir) rmSync(mcpDir, { recursive: true, force: true });
27+
});
28+
29+
it("emits mcp.config.json, server.ts, and README.md", () => {
30+
expect(existsSync(join(mcpDir, "mcp.config.json"))).toBe(true);
31+
expect(existsSync(join(mcpDir, "server.ts"))).toBe(true);
32+
expect(existsSync(join(mcpDir, "README.md"))).toBe(true);
33+
});
34+
35+
it("seeds mcp.config.json with the generated resourceTypes and safe defaults", () => {
36+
const cfg = JSON.parse(readFileSync(join(mcpDir, "mcp.config.json"), "utf-8"));
37+
expect(cfg.version).toBe("r4");
38+
expect(Array.isArray(cfg.resourceTypes)).toBe(true);
39+
expect(cfg.resourceTypes.length).toBeGreaterThan(0);
40+
expect(cfg.resourceTypes).toEqual([...cfg.resourceTypes].sort());
41+
expect(cfg.writes).toEqual([]);
42+
expect(cfg.confirmWrites).toBe(true);
43+
expect(cfg.dryRun).toBe(false);
44+
expect(cfg.defaultSearchCount).toBe(20);
45+
expect(cfg.maxResponseBytes).toBe(65536);
46+
});
47+
48+
it("writes a server.ts shim that calls @fhir-dsl/mcp's createServer", () => {
49+
const src = readFileSync(join(mcpDir, "server.ts"), "utf-8");
50+
expect(src).toContain('import { createServer, JsonLogAuditSink, stdioTransport } from "@fhir-dsl/mcp"');
51+
expect(src).toContain("process.env.FHIR_BASE_URL");
52+
expect(src).toContain("mcp.config.json");
53+
expect(src).toContain("await server.listen(stdioTransport())");
54+
});
55+
56+
it("README references the FHIR_BASE_URL env var", () => {
57+
const readme = readFileSync(join(mcpDir, "README.md"), "utf-8");
58+
expect(readme).toContain("FHIR_BASE_URL");
59+
expect(readme).toContain("fhir-gen generate --mcp");
60+
});
61+
});

0 commit comments

Comments
 (0)