semiotic 3.1.0 → 3.1.2

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 CHANGED
@@ -333,6 +333,110 @@ const svg = renderToStaticSVG("xy", {
333
333
  })
334
334
  ```
335
335
 
336
+ ## MCP Server
337
+
338
+ Semiotic ships with an [MCP server](https://modelcontextprotocol.io) that lets AI coding assistants render charts, diagnose configuration problems, discover schemas, and get chart recommendations via tool calls.
339
+
340
+ ### Setup
341
+
342
+ Add to your MCP client config (e.g. `claude_desktop_config.json` for Claude Desktop):
343
+
344
+ ```json
345
+ {
346
+ "mcpServers": {
347
+ "semiotic": {
348
+ "command": "npx",
349
+ "args": ["semiotic-mcp"]
350
+ }
351
+ }
352
+ }
353
+ ```
354
+
355
+ No API keys or authentication required. The server runs locally via stdio.
356
+
357
+ ### Tools
358
+
359
+ | Tool | Description |
360
+ |------|-------------|
361
+ | **`renderChart`** | Render a Semiotic chart to static SVG. Supports the components returned by `getSchema` that are marked `[renderable]`. Pass `{ component: "LineChart", props: { data: [...], xAccessor: "x", yAccessor: "y" } }`. Returns SVG string or validation errors with fix suggestions. |
362
+ | **`getSchema`** | Return the prop schema for a specific component. Pass `{ component: "LineChart" }` to get its props, or omit `component` to list all 30 chart types. Use before `renderChart` to look up valid props. |
363
+ | **`suggestChart`** | Recommend chart types for a data sample. Pass `{ data: [{...}, ...] }` with 1–5 sample objects. Optionally include `intent` (`"comparison"`, `"trend"`, `"distribution"`, `"relationship"`, `"composition"`, `"geographic"`, `"network"`, `"hierarchy"`). Returns ranked suggestions with example props. |
364
+ | **`diagnoseConfig`** | Check a chart configuration for common problems — empty data, bad dimensions, missing accessors, wrong data shape, and more. Returns a human-readable diagnostic report with actionable fixes. |
365
+ | **`reportIssue`** | Generate a pre-filled GitHub issue URL for bug reports or feature requests. Pass `{ title: "...", body: "...", labels: ["bug"] }`. Returns a URL the user can open to submit. |
366
+
367
+ ### Example: get schema for a component
368
+
369
+ ```
370
+ Tool: getSchema
371
+ Args: { "component": "LineChart" }
372
+ → Returns: { "name": "LineChart", "description": "...", "parameters": { "properties": { "data": ..., "xAccessor": ..., ... } } }
373
+ ```
374
+
375
+ ### Example: suggest a chart for your data
376
+
377
+ ```
378
+ Tool: suggestChart
379
+ Args: {
380
+ "data": [
381
+ { "month": "Jan", "revenue": 120, "region": "East" },
382
+ { "month": "Feb", "revenue": 180, "region": "West" }
383
+ ]
384
+ }
385
+ → Returns:
386
+ 1. BarChart (high confidence) — categorical field (region) with values (revenue)
387
+ 2. StackedBarChart (medium confidence) — two categorical fields (month, region)
388
+ 3. DonutChart (medium confidence) — 2 categories — proportional composition
389
+ ```
390
+
391
+ ### Example: render a chart
392
+
393
+ ```
394
+ Tool: renderChart
395
+ Args: {
396
+ "component": "BarChart",
397
+ "props": {
398
+ "data": [
399
+ { "category": "Q1", "revenue": 120 },
400
+ { "category": "Q2", "revenue": 180 },
401
+ { "category": "Q3", "revenue": 150 }
402
+ ],
403
+ "categoryAccessor": "category",
404
+ "valueAccessor": "revenue"
405
+ }
406
+ }
407
+ → Returns: <svg>...</svg>
408
+ ```
409
+
410
+ ### Example: diagnose a broken config
411
+
412
+ ```
413
+ Tool: diagnoseConfig
414
+ Args: { "component": "LineChart", "props": { "data": [] } }
415
+ → Returns: ✗ [EMPTY_DATA] data is an empty array — Fix: provide at least one data point
416
+ ```
417
+
418
+ ### Example: report an issue
419
+
420
+ ```
421
+ Tool: reportIssue
422
+ Args: {
423
+ "title": "Bug: BarChart tooltip shows undefined for custom accessor",
424
+ "body": "When using valueAccessor='amount', tooltip displays 'undefined'.\n\ndiagnoseConfig output: ✓ no issues detected.",
425
+ "labels": ["bug"]
426
+ }
427
+ → Returns: Open this URL to submit the issue: https://github.com/nteract/semiotic/issues/new?...
428
+ ```
429
+
430
+ ### CLI alternative
431
+
432
+ For quick validation without an MCP client:
433
+
434
+ ```bash
435
+ npx semiotic-ai --doctor # validate component + props JSON
436
+ npx semiotic-ai --schema # dump all chart schemas
437
+ npx semiotic-ai --compact # compact schema (fewer tokens)
438
+ ```
439
+
336
440
  ## Documentation
337
441
 
338
442
  [Interactive docs and examples](https://semiotic.nteract.io)
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.COMPONENT_REGISTRY = void 0;
4
4
  const ai_1 = require("semiotic/ai");
5
+ const geo_1 = require("semiotic/geo");
5
6
  exports.COMPONENT_REGISTRY = {
6
7
  LineChart: { component: ai_1.LineChart, category: "xy" },
7
8
  AreaChart: { component: ai_1.AreaChart, category: "xy" },
@@ -25,4 +26,8 @@ exports.COMPONENT_REGISTRY = {
25
26
  Treemap: { component: ai_1.Treemap, category: "network" },
26
27
  CirclePack: { component: ai_1.CirclePack, category: "network" },
27
28
  OrbitDiagram: { component: ai_1.OrbitDiagram, category: "network" },
29
+ ChoroplethMap: { component: geo_1.ChoroplethMap, category: "geo" },
30
+ ProportionalSymbolMap: { component: geo_1.ProportionalSymbolMap, category: "geo" },
31
+ FlowMap: { component: geo_1.FlowMap, category: "geo" },
32
+ DistanceCartogram: { component: geo_1.DistanceCartogram, category: "geo" },
28
33
  };
@@ -3,9 +3,12 @@
3
3
  /**
4
4
  * Semiotic MCP Server
5
5
  *
6
- * Exposes every HOC chart component as an MCP tool.
7
- * Accepts component props as tool arguments, renders to static SVG,
8
- * and returns the SVG string.
6
+ * Exposes five tools:
7
+ * 1. renderChart renders any HOC chart to static SVG
8
+ * 2. diagnoseConfig anti-pattern detector for chart configurations
9
+ * 3. reportIssue — generates a pre-filled GitHub issue URL for bugs/features
10
+ * 4. getSchema — returns the prop schema for a specific component
11
+ * 5. suggestChart — recommends chart types for a given data shape
9
12
  *
10
13
  * Usage (Claude Desktop / claude_desktop_config.json):
11
14
  * {
@@ -16,6 +19,9 @@
16
19
  * }
17
20
  * }
18
21
  * }
22
+ *
23
+ * HTTP mode (for remote inspectors / web clients):
24
+ * npx semiotic-mcp --http --port 3001
19
25
  */
20
26
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
21
27
  if (k2 === undefined) k2 = k;
@@ -53,51 +59,268 @@ var __importStar = (this && this.__importStar) || (function () {
53
59
  Object.defineProperty(exports, "__esModule", { value: true });
54
60
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
55
61
  const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
62
+ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
63
+ const zod_1 = require("zod");
56
64
  const fs = __importStar(require("fs"));
57
65
  const path = __importStar(require("path"));
66
+ const http = __importStar(require("http"));
58
67
  const renderHOCToSVG_1 = require("./renderHOCToSVG");
59
68
  const componentRegistry_1 = require("./componentRegistry");
60
69
  const ai_1 = require("semiotic/ai");
61
- // Load schema.json for tool definitions
70
+ // Load schema.json for version info
62
71
  const schemaPath = path.resolve(__dirname, "../schema.json");
63
72
  const schema = JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
64
- // Build MCP server
65
- const server = new mcp_js_1.McpServer({
66
- name: "semiotic",
67
- version: schema.version || "3.0.0",
68
- });
69
- // Register each chart component as a tool
70
- for (const toolDef of schema.tools) {
71
- const { name, description, parameters } = toolDef.function;
72
- // Skip realtime charts (ref-based, can't render to static SVG)
73
- if (name.startsWith("Realtime"))
74
- continue;
75
- // Skip components not in registry
76
- if (!componentRegistry_1.COMPONENT_REGISTRY[name])
77
- continue;
78
- // Register the tool use raw z.any() style since we have our own validation
79
- server.tool(name, description, {}, async (args) => {
80
- const result = (0, renderHOCToSVG_1.renderHOCToSVG)(name, args);
81
- if (result.error) {
82
- return {
83
- content: [{ type: "text", text: result.error }],
84
- isError: true,
85
- };
73
+ // Build component name → schema lookup from schema.json
74
+ const schemaByComponent = {};
75
+ for (const tool of schema.tools) {
76
+ schemaByComponent[tool.function.name] = tool.function;
77
+ }
78
+ const componentNames = Object.keys(componentRegistry_1.COMPONENT_REGISTRY).sort();
79
+ const REPO = "nteract/semiotic";
80
+ async function getSchemaHandler(args) {
81
+ const component = args.component;
82
+ if (!component) {
83
+ const all = Object.keys(schemaByComponent).sort();
84
+ const renderable = new Set(Object.keys(componentRegistry_1.COMPONENT_REGISTRY));
85
+ const list = all.map(name => renderable.has(name) ? `${name} [renderable]` : name);
86
+ return {
87
+ content: [{ type: "text", text: `Available components (${all.length}):\n${list.join(", ")}\n\nComponents marked [renderable] can be rendered to SVG via renderChart. Others (Realtime*) require a browser environment.\n\nPass { component: '<name>' } to get the prop schema for a specific component.` }],
88
+ };
89
+ }
90
+ const entry = schemaByComponent[component];
91
+ if (!entry) {
92
+ const available = Object.keys(schemaByComponent).sort();
93
+ return {
94
+ content: [{ type: "text", text: `Unknown component "${component}". Available: ${available.join(", ")}` }],
95
+ isError: true,
96
+ };
97
+ }
98
+ const renderableNote = componentRegistry_1.COMPONENT_REGISTRY[component] ? "This component can be rendered to SVG via renderChart." : "This component requires a browser environment and cannot be rendered via renderChart.";
99
+ return {
100
+ content: [{ type: "text", text: `${renderableNote}\n\n${JSON.stringify(entry, null, 2)}` }],
101
+ };
102
+ }
103
+ async function suggestChartHandler(args) {
104
+ const data = args.data;
105
+ const intent = args.intent;
106
+ if (!data || !Array.isArray(data) || data.length === 0) {
107
+ return {
108
+ content: [{ type: "text", text: "Pass { data: [{ ... }, ...] } with 1-5 sample data objects. Optionally include intent: 'comparison' | 'trend' | 'distribution' | 'relationship' | 'composition' | 'geographic' | 'network' | 'hierarchy'." }],
109
+ isError: true,
110
+ };
111
+ }
112
+ const sample = data[0];
113
+ if (!sample || typeof sample !== "object") {
114
+ return {
115
+ content: [{ type: "text", text: "Data items must be objects with key-value pairs." }],
116
+ isError: true,
117
+ };
118
+ }
119
+ const keys = Object.keys(sample);
120
+ const suggestions = [];
121
+ // Classify fields
122
+ const numericFields = [];
123
+ const stringFields = [];
124
+ const dateFields = [];
125
+ const geoFields = {};
126
+ const networkFields = {};
127
+ const hierarchyFields = {};
128
+ for (const key of keys) {
129
+ const values = data.map(d => d[key]).filter(v => v != null);
130
+ if (values.length === 0)
131
+ continue;
132
+ const first = values[0];
133
+ if (typeof first === "number") {
134
+ numericFields.push(key);
135
+ }
136
+ else if (typeof first === "string") {
137
+ if (/^\d{4}[-/]\d{2}/.test(first) && !isNaN(Date.parse(first))) {
138
+ dateFields.push(key);
139
+ }
140
+ else {
141
+ stringFields.push(key);
142
+ }
143
+ }
144
+ const kl = key.toLowerCase();
145
+ if (kl === "lat" || kl === "latitude")
146
+ geoFields.lat = key;
147
+ if (kl === "lon" || kl === "lng" || kl === "longitude")
148
+ geoFields.lon = key;
149
+ if (kl === "source" || kl === "from")
150
+ networkFields.source = key;
151
+ if (kl === "target" || kl === "to")
152
+ networkFields.target = key;
153
+ if (kl === "value" || kl === "weight" || kl === "amount")
154
+ networkFields.value = key;
155
+ if (kl === "children" || kl === "values")
156
+ hierarchyFields.children = key;
157
+ if (kl === "parent")
158
+ hierarchyFields.parent = key;
159
+ }
160
+ const hasTime = dateFields.length > 0;
161
+ const hasCat = stringFields.length > 0;
162
+ const hasNum = numericFields.length > 0;
163
+ const hasGeo = geoFields.lat && geoFields.lon;
164
+ const hasNetwork = networkFields.source && networkFields.target;
165
+ const hasHierarchy = hierarchyFields.children || hierarchyFields.parent;
166
+ // Network data
167
+ if (hasNetwork && (!intent || intent === "network")) {
168
+ const src = networkFields.source;
169
+ const tgt = networkFields.target;
170
+ if (networkFields.value) {
171
+ suggestions.push({
172
+ component: "SankeyDiagram",
173
+ confidence: "high",
174
+ reason: `Data has ${src}→${tgt} with ${networkFields.value} — ideal for flow visualization`,
175
+ props: { edges: "data", sourceAccessor: `"${src}"`, targetAccessor: `"${tgt}"`, valueAccessor: `"${networkFields.value}"` },
176
+ });
177
+ }
178
+ suggestions.push({
179
+ component: "ForceDirectedGraph",
180
+ confidence: networkFields.value ? "medium" : "high",
181
+ reason: `Data has ${src}→${tgt} edges — force layout shows network structure. Nodes are auto-inferred from edges when not provided.`,
182
+ props: { edges: "data", sourceAccessor: `"${src}"`, targetAccessor: `"${tgt}"` },
183
+ });
184
+ }
185
+ // Hierarchy data
186
+ if (hasHierarchy && (!intent || intent === "hierarchy")) {
187
+ suggestions.push({
188
+ component: "Treemap",
189
+ confidence: "high",
190
+ reason: `Data has nested ${hierarchyFields.children || "parent"} structure — treemap shows hierarchical proportions`,
191
+ props: { data: "rootObject", childrenAccessor: `"${hierarchyFields.children || "children"}"`, ...(numericFields[0] ? { valueAccessor: `"${numericFields[0]}"` } : {}) },
192
+ });
193
+ suggestions.push({
194
+ component: "TreeDiagram",
195
+ confidence: "medium",
196
+ reason: "Tree layout shows hierarchical relationships",
197
+ props: { data: "rootObject", childrenAccessor: `"${hierarchyFields.children || "children"}"` },
198
+ });
199
+ }
200
+ // Geographic data
201
+ if (hasGeo && (!intent || intent === "geographic")) {
202
+ const sizeField = numericFields.find(f => f !== geoFields.lat && f !== geoFields.lon);
203
+ suggestions.push({
204
+ component: "ProportionalSymbolMap",
205
+ confidence: "high",
206
+ reason: `Data has ${geoFields.lat}/${geoFields.lon} coordinates — map shows spatial distribution`,
207
+ props: { points: "data", xAccessor: `"${geoFields.lon}"`, yAccessor: `"${geoFields.lat}"`, ...(sizeField ? { sizeBy: `"${sizeField}"` } : {}) },
208
+ });
209
+ }
210
+ // Time series
211
+ if (hasTime && hasNum && (!intent || intent === "trend")) {
212
+ const timeField = dateFields[0];
213
+ const valueField = numericFields[0];
214
+ suggestions.push({
215
+ component: "LineChart",
216
+ confidence: "high",
217
+ reason: `Data has dates (${timeField}) and numeric values (${valueField}) — line chart shows trends over time`,
218
+ props: { data: "data", xAccessor: `"${timeField}"`, yAccessor: `"${valueField}"`, ...(hasCat ? { lineBy: `"${stringFields[0]}"`, colorBy: `"${stringFields[0]}"` } : {}) },
219
+ });
220
+ if (hasCat) {
221
+ suggestions.push({
222
+ component: "StackedAreaChart",
223
+ confidence: "medium",
224
+ reason: `Multiple categories (${stringFields[0]}) over time — stacked area shows composition trends`,
225
+ props: { data: "data", xAccessor: `"${timeField}"`, yAccessor: `"${valueField}"`, areaBy: `"${stringFields[0]}"`, colorBy: `"${stringFields[0]}"` },
226
+ });
227
+ }
228
+ }
229
+ // Categorical + numeric
230
+ if (hasCat && hasNum && (!intent || intent === "comparison" || intent === "composition" || intent === "distribution")) {
231
+ const catField = stringFields[0];
232
+ const valField = numericFields[0];
233
+ if (!intent || intent === "comparison") {
234
+ suggestions.push({
235
+ component: "BarChart",
236
+ confidence: hasTime ? "medium" : "high",
237
+ reason: `Categorical field (${catField}) with values (${valField}) — bar chart for comparison`,
238
+ props: { data: "data", categoryAccessor: `"${catField}"`, valueAccessor: `"${valField}"` },
239
+ });
86
240
  }
241
+ if (stringFields.length >= 2 && (!intent || intent === "composition")) {
242
+ suggestions.push({
243
+ component: "StackedBarChart",
244
+ confidence: "medium",
245
+ reason: `Two categorical fields (${stringFields.join(", ")}) — stacked bar shows composition within categories`,
246
+ props: { data: "data", categoryAccessor: `"${catField}"`, valueAccessor: `"${valField}"`, stackBy: `"${stringFields[1]}"` },
247
+ });
248
+ }
249
+ if (!intent || intent === "distribution") {
250
+ suggestions.push({
251
+ component: "Histogram",
252
+ confidence: "medium",
253
+ reason: `Numeric distribution of ${valField} — histogram shows value spread`,
254
+ props: { data: "data", categoryAccessor: `"${catField}"`, valueAccessor: `"${valField}"` },
255
+ });
256
+ }
257
+ if (!intent || intent === "composition") {
258
+ const uniqueCats = new Set(data.map(d => d[catField])).size;
259
+ if (uniqueCats <= 8) {
260
+ suggestions.push({
261
+ component: "DonutChart",
262
+ confidence: "medium",
263
+ reason: `${uniqueCats} categories — donut chart shows proportional composition`,
264
+ props: { data: "data", categoryAccessor: `"${catField}"`, valueAccessor: `"${valField}"` },
265
+ });
266
+ }
267
+ }
268
+ }
269
+ // Two numeric fields → scatterplot
270
+ if (numericFields.length >= 2 && (!intent || intent === "relationship")) {
271
+ const xField = numericFields[0];
272
+ const yField = numericFields[1];
273
+ suggestions.push({
274
+ component: "Scatterplot",
275
+ confidence: "high",
276
+ reason: `Two numeric fields (${xField}, ${yField}) — scatterplot shows relationships`,
277
+ props: { data: "data", xAccessor: `"${xField}"`, yAccessor: `"${yField}"`, ...(hasCat ? { colorBy: `"${stringFields[0]}"` } : {}), ...(numericFields[2] ? { sizeBy: `"${numericFields[2]}"` } : {}) },
278
+ });
279
+ if (numericFields.length >= 3) {
280
+ suggestions.push({
281
+ component: "BubbleChart",
282
+ confidence: "medium",
283
+ reason: `Three numeric fields — bubble chart adds size dimension to scatter`,
284
+ props: { data: "data", xAccessor: `"${xField}"`, yAccessor: `"${yField}"`, sizeBy: `"${numericFields[2]}"` },
285
+ });
286
+ }
287
+ if (numericFields.length >= 2 && hasCat) {
288
+ suggestions.push({
289
+ component: "Heatmap",
290
+ confidence: "medium",
291
+ reason: `Numeric values across dimensions — heatmap shows density/intensity`,
292
+ props: { data: "data", xAccessor: `"${xField}"`, yAccessor: `"${hasCat ? stringFields[0] : yField}"`, valueAccessor: `"${hasCat ? numericFields[0] : numericFields[2] || yField}"` },
293
+ });
294
+ }
295
+ }
296
+ // Fallback
297
+ if (suggestions.length === 0) {
298
+ const fieldSummary = `Fields: ${keys.join(", ")} (${numericFields.length} numeric, ${stringFields.length} categorical, ${dateFields.length} date)`;
87
299
  return {
88
- content: [{ type: "text", text: result.svg }],
300
+ content: [{ type: "text", text: `Could not confidently recommend a chart type.\n\n${fieldSummary}\n\nTry providing intent ('comparison', 'trend', 'distribution', 'relationship', 'composition', 'geographic', 'network', 'hierarchy') to narrow recommendations, or use getSchema to browse available components.` }],
89
301
  };
302
+ }
303
+ // Format output
304
+ const lines = suggestions.map((s, i) => {
305
+ const propsStr = Object.entries(s.props).map(([k, v]) => `${k}=${v}`).join(" ");
306
+ return `${i + 1}. **${s.component}** (${s.confidence} confidence)\n ${s.reason}\n \`<${s.component} ${propsStr} />\``;
90
307
  });
308
+ return {
309
+ content: [{ type: "text", text: lines.join("\n\n") }],
310
+ };
91
311
  }
92
- // ── Generic renderChart tool ─────────────────────────────────────────────
93
- // Accepts { component, props } — closes the agent feedback loop by letting
94
- // an LLM render any chart type in a single tool call.
95
- server.tool("renderChart", "Render any Semiotic chart to static SVG. Pass { component: 'LineChart', props: { data: [...], ... } }. Returns SVG string or validation errors.", {}, async (args) => {
312
+ async function renderChartHandler(args) {
96
313
  const component = args.component;
97
- const props = (args.props || args);
314
+ const props = args.props ?? {};
98
315
  if (!component) {
99
316
  return {
100
- content: [{ type: "text", text: "Missing 'component' field. Provide { component: 'LineChart', props: { ... } }." }],
317
+ content: [{ type: "text", text: `Missing 'component' field. Provide { component: '<name>', props: { ... } }. Available: ${componentNames.join(", ")}` }],
318
+ isError: true,
319
+ };
320
+ }
321
+ if (!componentRegistry_1.COMPONENT_REGISTRY[component]) {
322
+ return {
323
+ content: [{ type: "text", text: `Unknown component "${component}". Available: ${componentNames.join(", ")}` }],
101
324
  isError: true,
102
325
  };
103
326
  }
@@ -111,13 +334,10 @@ server.tool("renderChart", "Render any Semiotic chart to static SVG. Pass { comp
111
334
  return {
112
335
  content: [{ type: "text", text: result.svg }],
113
336
  };
114
- });
115
- // ── diagnoseConfig tool ──────────────────────────────────────────────────
116
- // Anti-pattern detector: checks for common failure modes and returns
117
- // actionable fix instructions.
118
- server.tool("diagnoseConfig", "Diagnose a Semiotic chart configuration for common problems (empty data, bad dimensions, missing accessors, wrong data shape, etc). Pass { component: 'LineChart', props: { ... } }. Returns structured diagnoses with fix instructions.", {}, async (args) => {
337
+ }
338
+ async function diagnoseConfigHandler(args) {
119
339
  const component = args.component;
120
- const props = (args.props || args);
340
+ const props = args.props ?? {};
121
341
  if (!component) {
122
342
  return {
123
343
  content: [{ type: "text", text: "Missing 'component' field. Provide { component: 'LineChart', props: { ... } }." }],
@@ -126,13 +346,13 @@ server.tool("diagnoseConfig", "Diagnose a Semiotic chart configuration for commo
126
346
  }
127
347
  const result = (0, ai_1.diagnoseConfig)(component, props);
128
348
  if (result.ok) {
129
- const warnings = result.diagnoses.filter(d => d.severity === "warning");
349
+ const warnings = result.diagnoses.filter((d) => d.severity === "warning");
130
350
  const msg = warnings.length > 0
131
- ? `Configuration looks good with ${warnings.length} warning(s):\n${warnings.map(w => `⚠ [${w.code}] ${w.message}\n Fix: ${w.fix}`).join("\n")}`
351
+ ? `Configuration looks good with ${warnings.length} warning(s):\n${warnings.map((w) => `⚠ [${w.code}] ${w.message}\n Fix: ${w.fix}`).join("\n")}`
132
352
  : `✓ Configuration looks good — no issues detected.`;
133
353
  return { content: [{ type: "text", text: msg }] };
134
354
  }
135
- const lines = result.diagnoses.map(d => {
355
+ const lines = result.diagnoses.map((d) => {
136
356
  const icon = d.severity === "error" ? "✗" : "⚠";
137
357
  const fixLine = d.fix ? `\n Fix: ${d.fix}` : "";
138
358
  return `${icon} [${d.code}] ${d.message}${fixLine}`;
@@ -141,11 +361,123 @@ server.tool("diagnoseConfig", "Diagnose a Semiotic chart configuration for commo
141
361
  content: [{ type: "text", text: lines.join("\n") }],
142
362
  isError: true,
143
363
  };
144
- });
145
- // Start the server
364
+ }
365
+ async function reportIssueHandler(args) {
366
+ const title = args.title;
367
+ const body = args.body;
368
+ const labels = args.labels;
369
+ if (!title) {
370
+ return {
371
+ content: [{ type: "text", text: "Missing 'title' field. Provide { title: 'Bug: ...', body: '...', labels?: ['bug'] }." }],
372
+ isError: true,
373
+ };
374
+ }
375
+ const params = new URLSearchParams();
376
+ params.set("title", title);
377
+ if (body)
378
+ params.set("body", body);
379
+ if (labels) {
380
+ const labelList = Array.isArray(labels) ? labels.join(",") : labels;
381
+ params.set("labels", labelList);
382
+ }
383
+ const url = `https://github.com/${REPO}/issues/new?${params.toString()}`;
384
+ return {
385
+ content: [{ type: "text", text: `Open this URL to submit the issue:\n\n${url}` }],
386
+ };
387
+ }
388
+ // ── Server factory ───────────────────────────────────────────────────────
389
+ // Creates a fresh McpServer with all tools registered.
390
+ // HTTP mode needs one instance per session (McpServer can only connect to one transport).
391
+ // Stdio mode uses a single instance.
392
+ function createServer() {
393
+ const srv = new mcp_js_1.McpServer({
394
+ name: "semiotic",
395
+ version: schema.version || "3.0.0",
396
+ });
397
+ srv.tool("getSchema", `Return the prop schema for a Semiotic chart component. Pass { component: '<name>' } to get its props, or omit component to list all available components. Components marked [renderable] can be passed to renderChart for static SVG output.`, { component: zod_1.z.string().optional().describe("Component name, e.g. 'LineChart'. Omit to list all.") }, getSchemaHandler);
398
+ srv.tool("suggestChart", "Recommend Semiotic chart types for a given data sample. Pass { data: [...] } with 1-5 sample objects. Optionally pass intent to narrow suggestions. Returns ranked recommendations with example props.", {
399
+ data: zod_1.z.array(zod_1.z.record(zod_1.z.string(), zod_1.z.unknown())).min(1).max(5).describe("1-5 sample data objects"),
400
+ intent: zod_1.z.enum(["comparison", "trend", "distribution", "relationship", "composition", "geographic", "network", "hierarchy"]).optional().describe("Visualization intent to narrow suggestions"),
401
+ }, suggestChartHandler);
402
+ srv.tool("renderChart", `Render a Semiotic chart to static SVG. Returns SVG string or validation errors. Available components: ${componentNames.join(", ")}.`, {
403
+ component: zod_1.z.string().describe("Chart component name, e.g. 'LineChart', 'BarChart'"),
404
+ props: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional().describe("Chart props object, e.g. { data: [...], xAccessor: 'x' }."),
405
+ }, renderChartHandler);
406
+ srv.tool("diagnoseConfig", "Diagnose a Semiotic chart configuration for common problems (empty data, bad dimensions, missing accessors, wrong data shape, etc). Returns a human-readable diagnostic report with actionable fixes.", {
407
+ component: zod_1.z.string().describe("Chart component name, e.g. 'LineChart'"),
408
+ props: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional().describe("Chart props object, e.g. { data: [...], xAccessor: 'x' }."),
409
+ }, diagnoseConfigHandler);
410
+ srv.tool("reportIssue", "Generate a GitHub issue URL for Semiotic bug reports or feature requests. Returns a URL the user can open to submit. For rendering bugs, include the component name, props summary, and any diagnoseConfig output in the body.", {
411
+ title: zod_1.z.string().describe("Issue title, e.g. 'Bug: BarChart tooltip shows undefined'"),
412
+ body: zod_1.z.string().optional().describe("Issue body with details, reproduction steps, diagnoseConfig output"),
413
+ labels: zod_1.z.union([zod_1.z.array(zod_1.z.string()), zod_1.z.string()]).optional().describe("GitHub labels, e.g. ['bug'] or 'bug'"),
414
+ }, reportIssueHandler);
415
+ return srv;
416
+ }
417
+ // ── Startup ──────────────────────────────────────────────────────────────
418
+ const cliArgs = process.argv.slice(2);
419
+ const httpMode = cliArgs.includes("--http");
420
+ const portFlagIndex = cliArgs.indexOf("--port");
421
+ const parsedPort = portFlagIndex !== -1 && cliArgs[portFlagIndex + 1] != null
422
+ ? parseInt(cliArgs[portFlagIndex + 1], 10)
423
+ : NaN;
424
+ const port = Number.isFinite(parsedPort) ? parsedPort : 3001;
146
425
  async function main() {
147
- const transport = new stdio_js_1.StdioServerTransport();
148
- await server.connect(transport);
426
+ if (httpMode) {
427
+ // HTTP mode — session-based, one server+transport per session
428
+ const sessions = new Map();
429
+ const httpServer = http.createServer(async (req, res) => {
430
+ // CORS headers for browser-based inspectors
431
+ res.setHeader("Access-Control-Allow-Origin", "*");
432
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
433
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, mcp-session-id");
434
+ res.setHeader("Access-Control-Expose-Headers", "mcp-session-id");
435
+ if (req.method === "OPTIONS") {
436
+ res.writeHead(204);
437
+ res.end();
438
+ return;
439
+ }
440
+ const sessionId = req.headers["mcp-session-id"];
441
+ if (sessionId && sessions.has(sessionId)) {
442
+ // Existing session — route to its transport
443
+ const session = sessions.get(sessionId);
444
+ await session.transport.handleRequest(req, res);
445
+ }
446
+ else if (!sessionId) {
447
+ // New session — create server + transport
448
+ const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
449
+ sessionIdGenerator: () => `semiotic-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
450
+ });
451
+ const srv = createServer();
452
+ await srv.connect(transport);
453
+ transport.onclose = () => {
454
+ const sid = transport.sessionId;
455
+ if (sid)
456
+ sessions.delete(sid);
457
+ };
458
+ await transport.handleRequest(req, res);
459
+ const sid = transport.sessionId;
460
+ if (sid) {
461
+ sessions.set(sid, { server: srv, transport });
462
+ }
463
+ }
464
+ else {
465
+ // Session ID provided but not found — stale session
466
+ res.writeHead(400, { "Content-Type": "application/json" });
467
+ res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Unknown session. Send a request without mcp-session-id to start a new session." }, id: null }));
468
+ }
469
+ });
470
+ httpServer.listen(port, () => {
471
+ console.error(`Semiotic MCP server (HTTP) listening on http://localhost:${port}`);
472
+ console.error("Tools: getSchema, suggestChart, renderChart, diagnoseConfig, reportIssue");
473
+ });
474
+ }
475
+ else {
476
+ // Default: stdio mode for Claude Desktop, Claude Code, Cursor, etc.
477
+ const srv = createServer();
478
+ const transport = new stdio_js_1.StdioServerTransport();
479
+ await srv.connect(transport);
480
+ }
149
481
  }
150
482
  main().catch((err) => {
151
483
  console.error("MCP server error:", err);
@@ -53,12 +53,14 @@ function renderHOCToSVG(componentName, props) {
53
53
  error: `Unknown component "${componentName}". Available: ${Object.keys(componentRegistry_1.COMPONENT_REGISTRY).join(", ")}`,
54
54
  };
55
55
  }
56
- // Validate props
56
+ // Validate props (skip for components not in the validation map, e.g. geo)
57
57
  const validation = (0, ai_1.validateProps)(componentName, props);
58
- if (!validation.valid) {
58
+ const errors = validation.errors ?? [];
59
+ const isUnknownComponentOnly = errors.length === 1 && errors[0].startsWith("Unknown component");
60
+ if (!validation.valid && !isUnknownComponentOnly) {
59
61
  return {
60
62
  svg: null,
61
- error: `Validation errors:\n${validation.errors.join("\n")}`,
63
+ error: `Validation errors:\n${errors.join("\n")}`,
62
64
  };
63
65
  }
64
66
  // Disable hover (not useful in static SVG)
package/ai/schema.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "name": "semiotic",
4
- "version": "3.1.0",
4
+ "version": "3.1.2",
5
5
  "description": "React data visualization library for charts, networks, and beyond",
6
6
  "tools": [
7
7
  {
@@ -4,6 +4,8 @@ export interface ChartErrorProps {
4
4
  componentName: string;
5
5
  /** The error message to display */
6
6
  message: string;
7
+ /** Optional diagnostic suggestions from diagnoseConfig */
8
+ diagnosticHint?: string;
7
9
  /** Chart width */
8
10
  width: number;
9
11
  /** Chart height */
@@ -16,4 +18,4 @@ export interface ChartErrorProps {
16
18
  * Designed to be obvious in development but not alarming in production —
17
19
  * uses muted colors that adapt to light/dark backgrounds.
18
20
  */
19
- export default function ChartError({ componentName, message, width, height, }: ChartErrorProps): React.JSX.Element;
21
+ export default function ChartError({ componentName, message, diagnosticHint, width, height, }: ChartErrorProps): React.JSX.Element;