@malloy-publisher/server 0.0.206 → 0.0.208

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.
Files changed (46) hide show
  1. package/dist/app/api-doc.yaml +31 -0
  2. package/dist/app/assets/{EnvironmentPage-BYwBeC2F.js → EnvironmentPage-DDRxo015.js} +1 -1
  3. package/dist/app/assets/{HomePage-ivu4vdpj.js → HomePage-BeIoPOVO.js} +1 -1
  4. package/dist/app/assets/{MainPage-B2DnHEDU.js → MainPage-DHVFRXPc.js} +2 -2
  5. package/dist/app/assets/{MaterializationsPage-BZEuwF9P.js → MaterializationsPage-BYnr56IV.js} +1 -1
  6. package/dist/app/assets/{ModelPage-Dpu3bfPg.js → ModelPage-B8tF_hYc.js} +1 -1
  7. package/dist/app/assets/{PackagePage-B8PwcRHt.js → PackagePage-LzaaviPn.js} +1 -1
  8. package/dist/app/assets/{RouteError-BhbywAeC.js → RouteError-vAYvRAi3.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-C-JXsJG0.js → WorkbookPage-CTjs2hXr.js} +1 -1
  10. package/dist/app/assets/{core-pPlPr7jK.es-CNEOlxKB.js → core-AOmIKwkc.es-B29cca-e.js} +1 -1
  11. package/dist/app/assets/index-CVGIZdxd.js +40 -0
  12. package/dist/app/assets/{index-ChR1fKR2.js → index-Db2wvjL3.js} +3 -3
  13. package/dist/app/assets/index-Dj4uKosi.js +1760 -0
  14. package/dist/app/assets/index.umd-BRRXibWA.js +2467 -0
  15. package/dist/app/index.html +1 -1
  16. package/dist/server.mjs +10537 -6626
  17. package/package.json +13 -11
  18. package/src/health.ts +6 -0
  19. package/src/materialization_metrics.ts +55 -0
  20. package/src/mcp/agent_server.protocol.spec.ts +78 -0
  21. package/src/mcp/agent_server.spec.ts +18 -0
  22. package/src/mcp/agent_server.ts +144 -0
  23. package/src/mcp/server.ts +3 -0
  24. package/src/mcp/skills/build_skills_bundle.spec.ts +51 -0
  25. package/src/mcp/skills/build_skills_bundle.ts +66 -0
  26. package/src/mcp/skills/skills_bundle.json +1 -0
  27. package/src/mcp/skills/skills_bundle.spec.ts +33 -0
  28. package/src/mcp/tools/docs_search/build_docs_index.ts +132 -0
  29. package/src/mcp/tools/docs_search/malloy_docs_index.json +1 -0
  30. package/src/mcp/tools/docs_search_tool.spec.ts +32 -0
  31. package/src/mcp/tools/docs_search_tool.ts +138 -0
  32. package/src/mcp/tools/get_context_eval.ts +126 -0
  33. package/src/mcp/tools/get_context_tool.spec.ts +44 -0
  34. package/src/mcp/tools/get_context_tool.ts +341 -0
  35. package/src/server.ts +14 -0
  36. package/src/service/environment.ts +59 -1
  37. package/src/service/materialization_service.spec.ts +272 -0
  38. package/src/service/materialization_service.ts +67 -45
  39. package/src/service/model.ts +19 -2
  40. package/src/service/package.ts +92 -22
  41. package/tests/integration/materialization/manifest_binding.integration.spec.ts +25 -6
  42. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +94 -0
  43. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +6 -0
  44. package/dist/app/assets/index-BHEm8Egc.js +0 -40
  45. package/dist/app/assets/index-BsvDrV14.js +0 -1812
  46. package/dist/app/assets/index.umd-BVLPYNuj.js +0 -2469
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/server",
3
3
  "description": "Malloy Publisher Server",
4
- "version": "0.0.206",
4
+ "version": "0.0.208",
5
5
  "main": "dist/server.mjs",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.mjs"
@@ -36,16 +36,16 @@
36
36
  "@azure/storage-blob": "^12.26.0",
37
37
  "@duckdb/node-api": "1.5.3-r.2",
38
38
  "@google-cloud/storage": "^7.16.0",
39
- "@malloydata/db-bigquery": "^0.0.405",
40
- "@malloydata/db-databricks": "^0.0.405",
41
- "@malloydata/db-duckdb": "^0.0.405",
42
- "@malloydata/db-mysql": "^0.0.405",
43
- "@malloydata/db-postgres": "^0.0.405",
44
- "@malloydata/db-snowflake": "^0.0.405",
45
- "@malloydata/db-trino": "^0.0.405",
46
- "@malloydata/malloy": "^0.0.405",
47
- "@malloydata/malloy-sql": "^0.0.405",
48
- "@malloydata/render-validator": "^0.0.405",
39
+ "@malloydata/db-bigquery": "^0.0.415",
40
+ "@malloydata/db-databricks": "^0.0.415",
41
+ "@malloydata/db-duckdb": "^0.0.415",
42
+ "@malloydata/db-mysql": "^0.0.415",
43
+ "@malloydata/db-postgres": "^0.0.415",
44
+ "@malloydata/db-snowflake": "^0.0.415",
45
+ "@malloydata/db-trino": "^0.0.415",
46
+ "@malloydata/malloy": "^0.0.415",
47
+ "@malloydata/malloy-sql": "^0.0.415",
48
+ "@malloydata/render-validator": "^0.0.415",
49
49
  "@modelcontextprotocol/sdk": "^1.13.2",
50
50
  "@opentelemetry/api": "^1.9.0",
51
51
  "@opentelemetry/auto-instrumentations-node": "^0.57.0",
@@ -65,6 +65,7 @@
65
65
  "globals": "^15.9.0",
66
66
  "handlebars": "^4.7.8",
67
67
  "http-proxy-middleware": "^3.0.5",
68
+ "lunr": "^2.3.6",
68
69
  "morgan": "^1.10.0",
69
70
  "node-cron": "^3.0.3",
70
71
  "recursive-readdir": "^2.2.3",
@@ -80,6 +81,7 @@
80
81
  "@types/bun": "^1.2.20",
81
82
  "@types/cors": "^2.8.12",
82
83
  "@types/express": "^4.17.14",
84
+ "@types/lunr": "^2.3.7",
83
85
  "@types/morgan": "^1.9.9",
84
86
  "@types/node": "^24.10.1",
85
87
  "@types/node-cron": "^3.0.11",
package/src/health.ts CHANGED
@@ -74,6 +74,7 @@ export function registerSignalHandlers(
74
74
  mcpServer: Server,
75
75
  shutdownDrainDurationSeconds: number = 0,
76
76
  shutdownGracefulCloseTimeoutSeconds: number = 0,
77
+ agentMcpServer?: Server,
77
78
  ): void {
78
79
  // Keep the process alive on SIGTERM — do not close the server.
79
80
  // K8s will SIGKILL after terminationGracePeriodSeconds (which cannot be caught).
@@ -96,6 +97,7 @@ export function registerSignalHandlers(
96
97
  server,
97
98
  mcpServer,
98
99
  shutdownGracefulCloseTimeoutSeconds,
100
+ agentMcpServer,
99
101
  );
100
102
  });
101
103
  }
@@ -114,6 +116,7 @@ export async function performGracefulShutdownAfterDrain(
114
116
  server: Server,
115
117
  mcpServer: Server,
116
118
  shutdownGracefulCloseTimeoutSeconds: number,
119
+ agentMcpServer?: Server,
117
120
  ): Promise<void> {
118
121
  const closeServer = (server: Server, name: string) =>
119
122
  new Promise<void>((resolve) => {
@@ -134,6 +137,9 @@ export async function performGracefulShutdownAfterDrain(
134
137
  await Promise.all([
135
138
  closeServer(server, "Main server"),
136
139
  closeServer(mcpServer, "MCP server"),
140
+ ...(agentMcpServer
141
+ ? [closeServer(agentMcpServer, "Agent MCP server")]
142
+ : []),
137
143
  ]);
138
144
 
139
145
  try {
@@ -17,9 +17,14 @@ import { metrics, type Counter, type Histogram } from "@opentelemetry/api";
17
17
 
18
18
  export type MaterializationRound = "round1" | "round2" | "auto";
19
19
  export type MaterializationOutcome = "success" | "failed" | "cancelled";
20
+ /** Manifest bind outcome: timeout is split out from generic failure on purpose. */
21
+ export type ManifestBindOutcome = "success" | "failure" | "timeout";
20
22
 
21
23
  let roundCounter: Counter | null = null;
22
24
  let roundDuration: Histogram | null = null;
25
+ let manifestBindCounter: Counter | null = null;
26
+ let sourceBuildDuration: Histogram | null = null;
27
+ let dropTablesCounter: Counter | null = null;
23
28
 
24
29
  function ensureTelemetry(): { counter: Counter; duration: Histogram } {
25
30
  if (roundCounter && roundDuration) {
@@ -59,8 +64,58 @@ export function recordMaterializationRound(
59
64
  duration.record(durationMs, { round });
60
65
  }
61
66
 
67
+ /**
68
+ * Record a manifest-bind attempt (publisher binding a control-plane manifest to
69
+ * a package at load). `timeout` is distinguished from `failure` so operators can
70
+ * tell an unreachable/slow manifest store from a malformed manifest.
71
+ */
72
+ export function recordManifestBind(outcome: ManifestBindOutcome): void {
73
+ if (!manifestBindCounter) {
74
+ manifestBindCounter = metrics
75
+ .getMeter("publisher")
76
+ .createCounter("publisher_materialization_manifest_bind_total", {
77
+ description:
78
+ "Manifest bind attempts. Label: outcome ('success'|'failure'|'timeout').",
79
+ });
80
+ }
81
+ manifestBindCounter.add(1, { outcome });
82
+ }
83
+
84
+ /** Record the wall-clock duration of building one persist source's table. */
85
+ export function recordSourceBuildDuration(durationMs: number): void {
86
+ if (!sourceBuildDuration) {
87
+ sourceBuildDuration = metrics
88
+ .getMeter("publisher")
89
+ .createHistogram(
90
+ "publisher_materialization_source_build_duration_ms",
91
+ {
92
+ description:
93
+ "Wall-clock duration of building a single persist source.",
94
+ unit: "ms",
95
+ },
96
+ );
97
+ }
98
+ sourceBuildDuration.record(durationMs);
99
+ }
100
+
101
+ /** Record a best-effort physical-table drop on materialization delete. */
102
+ export function recordDropTables(outcome: "success" | "failure"): void {
103
+ if (!dropTablesCounter) {
104
+ dropTablesCounter = metrics
105
+ .getMeter("publisher")
106
+ .createCounter("publisher_materialization_drop_tables_total", {
107
+ description:
108
+ "Physical tables dropped on delete. Label: outcome ('success'|'failure').",
109
+ });
110
+ }
111
+ dropTablesCounter.add(1, { outcome });
112
+ }
113
+
62
114
  /** Visible for tests. Drops cached instruments so a fresh MeterProvider can capture emissions. */
63
115
  export function resetMaterializationTelemetryForTesting(): void {
64
116
  roundCounter = null;
65
117
  roundDuration = null;
118
+ manifestBindCounter = null;
119
+ sourceBuildDuration = null;
120
+ dropTablesCounter = null;
66
121
  }
@@ -0,0 +1,78 @@
1
+ import { beforeAll, describe, expect, it } from "bun:test";
2
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
4
+ import { initializeAgentMcpServer } from "./agent_server";
5
+ import type { EnvironmentStore } from "../service/environment_store";
6
+
7
+ /**
8
+ * End-to-end coverage of the agent MCP server over the real MCP protocol, using
9
+ * the SDK's in-memory transport (no HTTP, no network, no DuckDB). Exercises tool
10
+ * registration, the dual-channel prompts, a real tool call (searchDocs over the
11
+ * bundled index), and the getContext error path.
12
+ */
13
+ describe("agent MCP server over the MCP protocol (in-memory)", () => {
14
+ let client: Client;
15
+
16
+ beforeAll(async () => {
17
+ // searchDocs does not touch the store; getContext's error path only needs
18
+ // getEnvironment to reject, so a throwing stub is sufficient here.
19
+ const stubStore = {
20
+ getEnvironment: async (name: string): Promise<never> => {
21
+ throw new Error(`Environment not found: ${name}`);
22
+ },
23
+ } as unknown as EnvironmentStore;
24
+
25
+ const server = initializeAgentMcpServer(stubStore);
26
+ const [clientTransport, serverTransport] =
27
+ InMemoryTransport.createLinkedPair();
28
+ await server.connect(serverTransport);
29
+ client = new Client({ name: "agent-protocol-test", version: "0.0.0" });
30
+ await client.connect(clientTransport);
31
+ });
32
+
33
+ it("exposes exactly the two agent tools", async () => {
34
+ const { tools } = await client.listTools();
35
+ expect(new Set(tools.map((t) => t.name))).toEqual(
36
+ new Set(["malloy_getContext", "malloy_searchDocs"]),
37
+ );
38
+ });
39
+
40
+ it("exposes the skill set as dual-channel prompts", async () => {
41
+ const { prompts } = await client.listPrompts();
42
+ expect(prompts.length).toBeGreaterThanOrEqual(24);
43
+ expect(prompts.some((p) => p.name === "malloy-analysis")).toBe(true);
44
+ });
45
+
46
+ it("malloy_searchDocs returns relevant docs over the protocol", async () => {
47
+ const res = await client.callTool({
48
+ name: "malloy_searchDocs",
49
+ arguments: { query: "window functions" },
50
+ });
51
+ const content = res.content as Array<{ resource?: { text?: string } }>;
52
+ const results = JSON.parse(
53
+ content?.[0]?.resource?.text ?? "[]",
54
+ ) as Array<{
55
+ url: string;
56
+ }>;
57
+ expect(results.length).toBeGreaterThan(0);
58
+ expect(results[0].url.length).toBeGreaterThan(0);
59
+ });
60
+
61
+ it("malloy_getContext fails gracefully (isError) for an unknown environment", async () => {
62
+ const res = await client.callTool({
63
+ name: "malloy_getContext",
64
+ arguments: {
65
+ environmentName: "nope",
66
+ packageName: "nope",
67
+ query: "x",
68
+ },
69
+ });
70
+ expect(res.isError).toBe(true);
71
+ });
72
+
73
+ it("a skill prompt returns its body", async () => {
74
+ const res = await client.getPrompt({ name: "malloy-analysis" });
75
+ const content = res.messages[0].content as { text?: string };
76
+ expect((content.text ?? "").length).toBeGreaterThan(200);
77
+ });
78
+ });
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { agentServerInfo, initializeAgentMcpServer } from "./agent_server";
3
+ import type { EnvironmentStore } from "../service/environment_store";
4
+
5
+ describe("agent MCP server", () => {
6
+ it("advertises a distinct server name from the core MCP server", () => {
7
+ expect(agentServerInfo.name).toBe("malloy-publisher-agent-mcp-server");
8
+ });
9
+
10
+ it("initializes and registers its tools and skill prompts without throwing", () => {
11
+ // The store is captured for later handler calls, not used during
12
+ // registration, so a placeholder is sufficient for this smoke test.
13
+ const server = initializeAgentMcpServer(
14
+ {} as unknown as EnvironmentStore,
15
+ );
16
+ expect(server).toBeTruthy();
17
+ });
18
+ });
@@ -0,0 +1,144 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ import cors from "cors";
4
+ import express from "express";
5
+ import * as http from "http";
6
+ import { registerHealthEndpoints } from "../health";
7
+ import { logger } from "../logger";
8
+ import { EnvironmentStore } from "../service/environment_store";
9
+ import { registerDocsSearchTool } from "./tools/docs_search_tool";
10
+ import { registerGetContextTool } from "./tools/get_context_tool";
11
+ import skillsBundle from "./skills/skills_bundle.json";
12
+
13
+ const AGENT_MCP_ENDPOINT = "/mcp";
14
+
15
+ // Build-time bundle of the agent skills (see skills/build_skills_bundle.ts), exposed
16
+ // as MCP prompts for the dual-channel delivery below.
17
+ const AGENT_SKILLS = (
18
+ skillsBundle as {
19
+ skills: { name: string; description: string; body: string }[];
20
+ }
21
+ ).skills;
22
+
23
+ export const agentServerInfo = {
24
+ name: "malloy-publisher-agent-mcp-server",
25
+ version: "0.0.1",
26
+ displayName: "Malloy Publisher Agent MCP Server",
27
+ description:
28
+ "Agent retrieval tools (semantic model context and docs search) for Malloy, served on a separate MCP endpoint from the core Publisher MCP tools.",
29
+ };
30
+
31
+ /**
32
+ * Builds an MCP server exposing ONLY the agent retrieval tools (malloy_getContext,
33
+ * malloy_searchDocs). Kept deliberately separate from the core initializeMcpServer so
34
+ * these tools and their tests stay isolated from the existing MCP tool surface.
35
+ */
36
+ export function initializeAgentMcpServer(
37
+ environmentStore: EnvironmentStore,
38
+ ): McpServer {
39
+ const mcpServer = new McpServer(agentServerInfo);
40
+ registerGetContextTool(mcpServer, environmentStore);
41
+ registerDocsSearchTool(mcpServer, environmentStore);
42
+
43
+ // Dual-channel: also expose each skill as an MCP prompt, so hosts that ingest MCP
44
+ // but do not load skill files (e.g. Codex, ChatGPT, Cursor) can pull the same
45
+ // guidance. Skill-aware hosts (Claude Code/Desktop) use the native skill files.
46
+ // Note: MCP prompts are on-demand, so always-on "prevention" skills become
47
+ // on-demand here; see docs/agent-skills/design-principles.md.
48
+ for (const skill of AGENT_SKILLS) {
49
+ mcpServer.prompt(skill.name, skill.description, () => ({
50
+ messages: [
51
+ {
52
+ role: "user" as const,
53
+ content: { type: "text" as const, text: skill.body },
54
+ },
55
+ ],
56
+ }));
57
+ }
58
+
59
+ return mcpServer;
60
+ }
61
+
62
+ /**
63
+ * Starts the agent MCP server on its own HTTP listener (a third Publisher listener),
64
+ * using the same stateless StreamableHTTP transport pattern as the core MCP server.
65
+ * Returns the http.Server so the caller can manage shutdown.
66
+ */
67
+ export function startAgentMcpServer(
68
+ environmentStore: EnvironmentStore,
69
+ host: string,
70
+ port: number,
71
+ ): http.Server {
72
+ const agentMcpApp = express();
73
+ registerHealthEndpoints(agentMcpApp);
74
+ agentMcpApp.use(AGENT_MCP_ENDPOINT, express.json());
75
+ agentMcpApp.use(AGENT_MCP_ENDPOINT, cors());
76
+
77
+ agentMcpApp.all(AGENT_MCP_ENDPOINT, async (req, res) => {
78
+ if (req.method !== "POST") {
79
+ res.setHeader("Allow", "POST");
80
+ res.status(405).json({
81
+ jsonrpc: "2.0",
82
+ error: {
83
+ code: -32601,
84
+ message: "Method Not Allowed in Stateless Mode",
85
+ },
86
+ id: null,
87
+ });
88
+ return;
89
+ }
90
+
91
+ try {
92
+ const transport = new StreamableHTTPServerTransport({
93
+ sessionIdGenerator: undefined,
94
+ });
95
+ transport.onerror = (err: Error) => {
96
+ logger.error("[Agent MCP Transport Error]", {
97
+ error: err instanceof Error ? err.message : String(err),
98
+ });
99
+ };
100
+ const requestMcpServer = initializeAgentMcpServer(environmentStore);
101
+ await requestMcpServer.connect(transport);
102
+ res.on("close", () => {
103
+ transport.close().catch((err) => {
104
+ logger.error("[Agent MCP Transport Error] close failed", {
105
+ error: err instanceof Error ? err.message : String(err),
106
+ });
107
+ });
108
+ });
109
+ await transport.handleRequest(req, res, req.body);
110
+ } catch (err) {
111
+ logger.error("[Agent MCP] request handler error", {
112
+ error: err instanceof Error ? err.message : String(err),
113
+ });
114
+ if (!res.headersSent) {
115
+ res.status(500).json({
116
+ jsonrpc: "2.0",
117
+ error: { code: -32603, message: "Internal server error" },
118
+ id: null,
119
+ });
120
+ }
121
+ }
122
+ });
123
+
124
+ const agentMcpServer = agentMcpApp.listen(port, host, () => {
125
+ logger.info(
126
+ `Agent MCP server listening at http://${host}:${port}${AGENT_MCP_ENDPOINT}`,
127
+ );
128
+ });
129
+
130
+ // The agent MCP server is auxiliary: a bind failure here (e.g. EADDRINUSE)
131
+ // must not take down the main API or the core MCP server. Without this
132
+ // handler the listener's 'error' event would surface as an uncaught
133
+ // exception and crash the whole process at boot. Log and degrade instead.
134
+ agentMcpServer.on("error", (err: NodeJS.ErrnoException) => {
135
+ logger.error("Agent MCP server failed to start; continuing without it", {
136
+ host,
137
+ port,
138
+ code: err.code,
139
+ error: err.message,
140
+ });
141
+ });
142
+
143
+ return agentMcpServer;
144
+ }
package/src/mcp/server.ts CHANGED
@@ -52,6 +52,9 @@ export function initializeMcpServer(
52
52
 
53
53
  registerTools(mcpServer, environmentStore);
54
54
 
55
+ // Note: malloy_getContext and malloy_searchDocs are intentionally NOT registered
56
+ // here. They run on a separate, isolated agent MCP server (see mcp/agent_server.ts).
57
+
55
58
  logger.info("[MCP Init] Registering prompt capability...");
56
59
  registerPromptCapability(mcpServer, environmentStore);
57
60
 
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { parseSkill, unquote } from "./build_skills_bundle";
3
+
4
+ describe("build_skills_bundle parseSkill", () => {
5
+ it("parses name, description, and body from frontmatter", () => {
6
+ const md =
7
+ "---\nname: malloy-queries\ndescription: Query patterns.\n---\n# Body\n\nText.";
8
+ const s = parseSkill(md, "dir");
9
+ expect(s.name).toBe("malloy-queries");
10
+ expect(s.description).toBe("Query patterns.");
11
+ expect(s.body).toBe("# Body\n\nText.");
12
+ });
13
+
14
+ it("keeps colons in the description (line-based, not split on colon)", () => {
15
+ const md =
16
+ "---\nname: x\ndescription: Use when: a thing happens.\n---\nbody";
17
+ expect(parseSkill(md, "dir").description).toBe(
18
+ "Use when: a thing happens.",
19
+ );
20
+ });
21
+
22
+ it("tolerates CRLF line endings", () => {
23
+ const md = "---\r\nname: x\r\ndescription: d\r\n---\r\nbody line";
24
+ const s = parseSkill(md, "dir");
25
+ expect(s.name).toBe("x");
26
+ expect(s.description).toBe("d");
27
+ expect(s.body).toBe("body line");
28
+ });
29
+
30
+ it("falls back to the dir name and empty description when frontmatter is missing", () => {
31
+ const s = parseSkill("# Just a body, no frontmatter", "fallback-name");
32
+ expect(s.name).toBe("fallback-name");
33
+ expect(s.description).toBe("");
34
+ expect(s.body).toBe("# Just a body, no frontmatter");
35
+ });
36
+
37
+ it("strips surrounding quotes from values", () => {
38
+ const md = "---\nname: \"quoted-name\"\ndescription: 'q'\n---\nb";
39
+ const s = parseSkill(md, "dir");
40
+ expect(s.name).toBe("quoted-name");
41
+ expect(s.description).toBe("q");
42
+ });
43
+ });
44
+
45
+ describe("build_skills_bundle unquote", () => {
46
+ it("removes a single layer of surrounding quotes", () => {
47
+ expect(unquote('"hi"')).toBe("hi");
48
+ expect(unquote("'hi'")).toBe("hi");
49
+ expect(unquote("plain")).toBe("plain");
50
+ });
51
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Build a bundled JSON of the agent skills so the agent MCP server can expose them as
3
+ * MCP prompts (dual-channel): skill-aware hosts read the skill files directly, while
4
+ * hosts that only ingest MCP get the same guidance through these prompts.
5
+ *
6
+ * Committed as a build-time asset so the server has no runtime dependency on the
7
+ * skills directory layout. Regenerate when the skills change:
8
+ *
9
+ * bun run packages/server/src/mcp/skills/build_skills_bundle.ts <skills-dir> [out.json]
10
+ *
11
+ * <skills-dir> points at the repo's top-level skills/ directory.
12
+ */
13
+ import * as fs from "fs";
14
+ import * as path from "path";
15
+
16
+ interface SkillEntry {
17
+ name: string;
18
+ description: string;
19
+ body: string;
20
+ }
21
+
22
+ export function unquote(s: string): string {
23
+ return s.trim().replace(/^["']|["']$/g, "");
24
+ }
25
+
26
+ /** Parse a SKILL.md into its frontmatter name/description and Markdown body. */
27
+ export function parseSkill(md: string, dirName: string): SkillEntry {
28
+ const text = md.replace(/\r\n/g, "\n"); // tolerate CRLF-committed files
29
+ const fm = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
30
+ const front = fm ? fm[1] : "";
31
+ const body = (fm ? fm[2] : text).trim();
32
+ const name = unquote(front.match(/^name:\s*(.+)$/m)?.[1] ?? dirName);
33
+ const description = unquote(
34
+ front.match(/^description:\s*(.+)$/m)?.[1] ?? "",
35
+ );
36
+ return { name, description, body };
37
+ }
38
+
39
+ function main(): void {
40
+ const skillsDir = process.argv[2];
41
+ const outFile =
42
+ process.argv[3] || path.join(import.meta.dir, "skills_bundle.json");
43
+ if (!skillsDir || !fs.existsSync(skillsDir)) {
44
+ console.error(
45
+ `skills dir not found: ${skillsDir}\nUsage: bun run build_skills_bundle.ts <skills-dir> [out.json]`,
46
+ );
47
+ process.exit(1);
48
+ }
49
+
50
+ const skills: SkillEntry[] = [];
51
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
52
+ if (!entry.isDirectory()) continue;
53
+ const skillFile = path.join(skillsDir, entry.name, "SKILL.md");
54
+ if (!fs.existsSync(skillFile)) continue;
55
+ skills.push(parseSkill(fs.readFileSync(skillFile, "utf8"), entry.name));
56
+ }
57
+ skills.sort((a, b) => a.name.localeCompare(b.name));
58
+
59
+ fs.writeFileSync(outFile, JSON.stringify({ skills }));
60
+ console.log(`Wrote ${skills.length} skills to ${outFile}`);
61
+ }
62
+
63
+ // Only run when invoked directly (bun run ...), not when imported by a test.
64
+ if (import.meta.main) {
65
+ main();
66
+ }