From e53e8d73129a369e6cf161d4eccd55d6961e1de9 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Thu, 14 May 2026 18:18:24 +0100 Subject: [PATCH 1/4] feat: KQL expert skill with requires-mcp, trigger cleanup, syntax highlighting - Add kql-expert skill (SKILL.md) with 35 triggers, requires-mcp: fabric-rti-mcp - Add requires-mcp frontmatter support in skill-loader + approach-resolver - Add MCP enrichment wiring in agent index.ts - Add --mcp setup-fabric-rti CLI command - Clean generic triggers across 7 skills to prevent false matches - Add hljs-kql.ts grammar (derived from @kusto/monaco-kusto MIT) - Register KQL/Kusto syntax highlighting in markdown-renderer via createRequire - Add intent-matcher regression tests (27 tests: 22 positive + 5 guards) - Add skill-loader tests (8 tests) and approach-resolver MCP tests (8 tests) All 2396 tests pass. Typecheck clean. Fmt clean. Signed-off-by: Simon Davies --- skills/api-explorer/SKILL.md | 5 +- skills/data-processor/SKILL.md | 20 +- skills/kql-expert/SKILL.md | 506 +++++++++++++++++++++++++++ skills/mcp-services/SKILL.md | 6 +- skills/pdf-expert/SKILL.md | 6 +- skills/report-builder/SKILL.md | 12 +- skills/research-synthesiser/SKILL.md | 3 - skills/web-scraper/SKILL.md | 5 +- src/agent/approach-resolver.ts | 50 +++ src/agent/cli-parser.ts | 8 + src/agent/hljs-kql.ts | 387 ++++++++++++++++++++ src/agent/index.ts | 27 +- src/agent/markdown-renderer.ts | 13 + src/agent/mcp/setup-commands.ts | 63 ++++ src/agent/skill-loader.ts | 6 + tests/approach-resolver.test.ts | 130 +++++++ tests/intent-matcher.test.ts | 187 ++++++++++ tests/skill-loader.test.ts | 184 ++++++++++ 18 files changed, 1588 insertions(+), 30 deletions(-) create mode 100644 skills/kql-expert/SKILL.md create mode 100644 src/agent/hljs-kql.ts create mode 100644 tests/skill-loader.test.ts diff --git a/skills/api-explorer/SKILL.md b/skills/api-explorer/SKILL.md index 7d466e0..3a956e3 100644 --- a/skills/api-explorer/SKILL.md +++ b/skills/api-explorer/SKILL.md @@ -11,11 +11,12 @@ triggers: - API documentation - API reference - test endpoint - - request - - response - status code - rate limit - webhook + - API call + - REST API + - HTTP endpoint patterns: - fetch-and-process - data-extraction diff --git a/skills/data-processor/SKILL.md b/skills/data-processor/SKILL.md index 13e4abc..098a3f5 100644 --- a/skills/data-processor/SKILL.md +++ b/skills/data-processor/SKILL.md @@ -4,16 +4,16 @@ description: Transform, filter, and analyse data using sandbox handlers triggers: - CSV - JSON - - transform - - convert - - process - - analyse - - analyze - - data - - filter - - aggregate - - sort - - parse + - TSV + - CSV to JSON + - JSON to CSV + - data pipeline + - ETL + - tabular data + - parse CSV + - parse JSON + - transform CSV + - transform JSON patterns: - data-transformation - two-handler-pipeline diff --git a/skills/kql-expert/SKILL.md b/skills/kql-expert/SKILL.md new file mode 100644 index 0000000..7c263a0 --- /dev/null +++ b/skills/kql-expert/SKILL.md @@ -0,0 +1,506 @@ +--- +name: kql-expert +description: KQL language expertise for writing correct, efficient Kusto queries using Fabric RTI MCP or Azure MCP kusto tools +triggers: + - KQL + - Kusto + - ADX + - Azure Data Explorer + - Fabric Eventhouse + - Eventhouse + - log analysis + - time series + - anomaly detection + - kusto_query + - kusto_command + - Kusto query + - KQL query + - kusto.windows.net + - summarize by + - make-series + - dcount + - render timechart + - render piechart + - .show tables + - .show database + - Kusto cluster + - Kusto table + - ADX cluster + - Log Analytics + - Application Insights + - real-time intelligence + - telemetry + - mv-expand + - externaldata + - materialized view + - ingestion + - analyze logs + - query logs + - error spikes + - failed requests +patterns: + - fetch-and-process +antiPatterns: + - Don't guess KQL syntax — use the self-correction table and query checklist + - Don't switch query approaches on first error — fix the specific error first + - Don't scan large tables without pre-filtering with | where + - Don't use dynamic columns in by/on/order by without explicit casts + - Don't use extract_all without capturing groups in the regex + - Don't call kusto_query for management commands — use kusto_command for .show/.create/.alter + - Don't hardcode MCP tool schemas — call mcp_tool_info first + - Don't call MCP tools directly — execute them inside registered handler code +requires-mcp: + - fabric-rti-mcp +allowed-tools: + - register_handler + - execute_javascript + - execute_bash + - delete_handler + - get_handler_source + - edit_handler + - list_handlers + - reset_sandbox + - list_modules + - module_info + - list_plugins + - plugin_info + - manage_plugin + - list_mcp_servers + - mcp_server_info + - mcp_tool_info + - manage_mcp + - apply_profile + - configure_sandbox + - sandbox_help + - register_module + - write_output + - read_input + - read_output + - ask_user +--- + +# KQL Expert — Kusto Query Language Mastery + +> **Try it yourself**: All `✅` examples use the public help cluster: +> `https://help.kusto.windows.net`, database `Samples` (StormEvents, nyc_taxi, etc.). + +## 1. Running KQL with Fabric RTI MCP + +The `fabric-rti-mcp` MCP server exposes Kusto as MCP tools. Authentication +is handled transparently via Azure Identity. + +### Handler Execution Pattern + +MCP tools run inside registered handlers — never call them directly: + +```javascript +// 1. Connect the MCP server +manage_mcp({ action: "connect", name: "fabric-rti-mcp" }); + +// 2. Get tool schemas +mcp_tool_info({ name: "fabric-rti-mcp", query: "query" }); + +// 3. Apply mcp-network profile for wall-clock time +apply_profile({ profiles: "mcp-network" }); + +// 4. Register handler that imports MCP tools +register_handler({ + name: "run-kql", + code: ` + import { kusto_query } from "host:mcp-fabric-rti-mcp"; + export default async function(input) { + const result = await kusto_query({ + query: "StormEvents | summarize count() by State | top 5 by count_ desc", + cluster_uri: "https://help.kusto.windows.net", + database: "Samples" + }); + if (!result.ok) return { error: result.error }; + return result.data; + } + `, +}); + +// 5. Execute +execute_javascript({ handler: "run-kql" }); +``` + +### Available MCP Tools + +| Tool | Purpose | +| -------------------------------- | ---------------------------------------------------------- | +| `kusto_query` | Execute a KQL query on a database | +| `kusto_command` | Execute a management command (`.show`, `.create`, etc.) | +| `kusto_list_entities` | List databases, tables, external tables, functions, graphs | +| `kusto_describe_database` | Get schema for all entities in a database | +| `kusto_describe_database_entity` | Get schema for a specific entity | +| `kusto_sample_entity` | Get sample data from a table or entity | +| `kusto_graph_query` | Execute a graph query (snapshots or transient) | +| `kusto_ingest_inline_into_table` | Ingest inline CSV data into a table | +| `kusto_known_services` | List configured Kusto services | +| `kusto_get_shots` | Retrieve semantically similar query examples | +| `kusto_deeplink_from_query` | Build a deeplink URL for the web explorer | +| `kusto_show_queryplan` | Get the execution plan without running it | +| `kusto_diagnostics` | Get cluster health and capacity summary | + +### Query vs Management Commands + +KQL has two execution planes, each with its own MCP tool: + +| Plane | Tool | Starts with | Examples | +| -------------- | --------------- | --------------------------------------------- | --------------------------------------- | +| **Query** | `kusto_query` | Table name, `let`, `print`, `datatable` | `StormEvents \| where State == "TEXAS"` | +| **Management** | `kusto_command` | `.show`, `.create`, `.set`, `.drop`, `.alter` | `.show tables`, `.show table T schema` | + +### Exploration Workflow + +When encountering a new cluster or database: + +1. **List entities**: `kusto_list_entities(cluster_uri, entity_type="tables", database="MyDB")` +2. **Get schema**: `kusto_describe_database_entity(entity_name="MyTable", entity_type="table", ...)` +3. **Sample data**: `kusto_sample_entity(entity_name="MyTable", entity_type="table", sample_size=5, ...)` +4. **Count rows**: `kusto_query(query="MyTable | count", ...)` +5. **Run analysis**: `kusto_query(query="MyTable | where ... | summarize ...", ...)` + +## 2. Dynamic Type Discipline + +KQL's `dynamic` type is flexible but strict in certain contexts. A common mistake +is using a dynamic column in `summarize by`, `order by`, or `join on` without +casting. + +**The rule**: Any time you use a dynamic-typed column in `by`, `on`, or +`order by`, wrap it in an explicit cast. + +```kql +// ❌ ERROR: "Summarize group key 'Partners' is of a 'dynamic' type" +| summarize count() by Partners + +// ✅ FIX +| summarize count() by tostring(Partners) +``` + +```kql +// ❌ ERROR in join: dynamic join key +| join kind=inner other on $left.Area == $right.Area + +// ✅ FIX — cast both sides +| extend Area_str = tostring(Area) +| join kind=inner (other | extend Area_str = tostring(Area)) on Area_str +``` + +**Self-correction**: When you see "is of a 'dynamic' type" in an error, add +`tostring()`, `tolong()`, or `todouble()`. + +## 3. Join Patterns & Pitfalls + +KQL joins have constraints that differ from SQL. + +### Equality Only + +KQL join conditions support **only `==`**. No `<`, `>`, `!=`, or function calls. + +```kql +// ❌ ERROR: "Only equality is allowed in this context" +| join on geo_distance_2points(a.Lat, a.Lon, b.Lat, b.Lon) < 1000 + +// ✅ WORKAROUND — pre-bucket into spatial cells, then join on cell ID +| extend cell = geo_point_to_s2cell(Lon, Lat, 8) +| join kind=inner (other | extend cell = geo_point_to_s2cell(Lon, Lat, 8)) on cell +``` + +### Left/Right Attribute Matching + +Both sides of a join `on` clause must reference column entities only. + +```kql +// ❌ ERROR: "for each left attribute, right attribute should be selected" +| join kind=inner other on $left.col1 + +// ✅ FIX — specify both sides explicitly +| join kind=inner other on $left.col1 == $right.col1 +``` + +### Cardinality Check Before Large Joins + +**Always** check cardinality before joining tables with >10K rows. + +```kql +// Before joining, check how many rows each side contributes +TableA | summarize dcount(JoinKey) // → 25,000? Too many unconstrained +TableB | summarize dcount(JoinKey) // → 195? OK if filtered first +``` + +## 4. Regex in KQL + +### The extract_all Gotcha + +KQL's `extract_all` **requires capturing groups** in the regex: + +```kql +// ❌ ERROR: "extractall(): argument 2 must be a valid regex with [1..16] matching groups" +| extend words = extract_all(@"[a-zA-Z]{3,}", Text) + +// ✅ FIX — add parentheses around the pattern +| extend words = extract_all(@"([a-zA-Z]{3,})", Text) +``` + +### Regex Toolkit + +| Function | Use case | Example | +| ------------------------------- | ------------------------ | --------------------------------------------- | +| `extract(regex, group, source)` | Single match | `extract(@"User '([^']+)'", 1, Msg)` | +| `extract_all(regex, source)` | All matches (needs `()`) | `extract_all(@"(\w+)", Text)` | +| `parse` | Structured extraction | `parse Msg with * "User '" Sender "' sent" *` | +| `matches regex` | Boolean filter | `where Url matches regex @"^https?://"` | +| `replace_regex` | Find and replace | `replace_regex(Text, @"\s+", " ")` | + +## 5. Serialization Requirements + +Window functions need serialized (ordered) input. + +```kql +// ❌ ERROR: "Function 'row_cumsum' cannot be invoked. The row set must be serialized." +| summarize Online = sum(Direction) by bin(Timestamp, 5m) +| extend CumulativeOnline = row_cumsum(Online) + +// ✅ FIX — add | serialize (or | order by, which implicitly serializes) +| summarize Online = sum(Direction) by bin(Timestamp, 5m) +| order by Timestamp asc +| extend CumulativeOnline = row_cumsum(Online) +``` + +Functions requiring serialization: `row_number()`, `row_cumsum()`, `prev()`, +`next()`, `row_window_session()`. + +## 6. Memory-Safe Query Patterns + +### The Progression of Safety + +``` +Safest ──────────────────────────────────────────── Most dangerous +| count | take 10 | where + summarize | summarize (no filter) | full scan +``` + +### Rules for Large Tables (>1M rows) + +1. **Always start with `| count`** to understand table size +2. **Always `| where` before `| summarize`** — filter time range or category first +3. **Never `dcount()` on high-cardinality columns** without pre-filtering +4. **Check join cardinality** before executing (see Section 3) +5. **Use `materialize()`** for subqueries referenced multiple times + +### When You See `E_LOW_MEMORY_CONDITION` + +The query touched too much data. Options: + +- Add `| where` filters (time range, partition key) +- Reduce the number of `by` columns in `summarize` +- Break into smaller time windows and union results +- Use `| sample 10000` for exploratory work + +### When You See `E_RUNAWAY_QUERY` + +A join or aggregation produced too many output rows. Check join cardinality. + +## 7. Result Size Discipline + +| Query type | Safeguard | +| ---------------------------- | ------------------------------------------------ | +| Exploratory | Always end with `\| take 10` or `\| take 20` | +| Aggregation | Use `\| top 20 by ...` not unbounded `summarize` | +| Wide rows (vectors, JSON) | `\| project` only needed columns | +| `make_list()` / `make_set()` | Avoid on high-cardinality groups | +| Unknown size | Run `\| count` first | + +**The vector trap**: Tables with embedding columns (1536-dim float arrays) +produce ~30KB per row. Always `| project` away vector columns unless needed. + +## 8. String Comparison Strictness + +```kql +// ❌ ERROR: "Cannot compare values of types string and string" +| where geo_point_to_s2cell(Lon, Lat, 16) == other_cell + +// ✅ FIX — wrap both sides in tostring() +| where tostring(geo_point_to_s2cell(Lon, Lat, 16)) == tostring(other_cell) +``` + +## 9. Advanced Functions + +### Vector Similarity + +```kql +let target = pack_array(5.1, 3.5, 1.4, 0.2); +Iris +| extend Vec = pack_array(SepalLength, SepalWidth, PetalLength, PetalWidth) +| extend sim = series_cosine_similarity(Vec, target) +| top 5 by sim desc +``` + +### Geo Operations + +```kql +// Distance between two points (meters) +StormEvents | extend dist = geo_distance_2points(BeginLon, BeginLat, EndLon, EndLat) + +// Spatial bucketing for joins +StormEvents | extend cell = geo_point_to_s2cell(BeginLon, BeginLat, 8) +``` + +### Graph Queries + +Use the `kusto_graph_query` MCP tool for graph traversal: + +```kql +// Persistent graph model +graph("Simple") +| graph-match (src)-[e*1..5]->(dst) + where src.name == "Alice" + project src.name, dst.name, path_length = array_length(e) +``` + +### Time Series + +```kql +StormEvents +| make-series count() default=0 on StartTime step 1d +| extend anomalies = series_decompose_anomalies(count_) +``` + +## 10. Self-Correction Lookup Table + +When you encounter an error, look it up here before retrying: + +| Error message contains | Likely cause | Fix | +| -------------------------------------------------- | -------------------------------------- | ----------------------------------------------- | +| `is of a 'dynamic' type` | Dynamic column in `by`/`on`/`order by` | Wrap in `tostring()`/`tolong()` | +| `Only equality is allowed` | Range predicate in join | Pre-bucket with S2 cells or `bin()` | +| `extractall(): matching groups` | Missing `()` in regex | Add `()`: `@"(\w+)"` not `@"\w+"` | +| `row set must be serialized` | Window function on unsorted data | Add `\| serialize` or `\| order by` | +| `Cannot compare values of types string and string` | Computed string comparison | Add `tostring()` on both sides | +| `Failed to resolve column named 'X'` | Wrong column name | Use `kusto_describe_database_entity` to check | +| `E_LOW_MEMORY_CONDITION` | Query touched too much data | Add `\| where` filters, reduce time range | +| `E_RUNAWAY_QUERY` | Join produced too many rows | Check cardinality; add pre-filters | +| `for each left attribute, right attribute` | Join `on` incomplete | Use `on $left.X == $right.Y` | +| `needs to be bracketed` | Reserved word as identifier | Use `['keyword']` syntax | +| `Expected string literal in datetime()` | Bare integer in datetime | Use `datetime(2024-01-01)` not `datetime(2024)` | +| `Unexpected token` after `by` | Complex expression in summarize | `extend` first, then `summarize by` column | + +## 11. Datetime Pitfalls + +### Literal Format + +```kql +// ❌ WRONG — bare year is not a valid datetime +| where StartTime > datetime(2007) + +// ✅ RIGHT — always use full date format +| where StartTime > datetime(2007-01-01) +``` + +### Filtering by Year/Month/Hour + +```kql +// ❌ WRONG — comparing datetime to integer +| where StartTime == 2007 + +// ✅ RIGHT — use datetime_part() +| where datetime_part("year", StartTime) == 2007 + +// ✅ ALSO RIGHT — use between +| where StartTime between (datetime(2007-01-01) .. datetime(2007-12-31T23:59:59)) +``` + +### Useful Datetime Functions + +| Function | Purpose | Example | +| --------------------------- | -------------------- | --------------------------------------------------------------------------------- | +| `bin(ts, 1h)` | Round down to bucket | `bin(Timestamp, 1d)` | +| `startofmonth(ts)` | First day of month | `startofmonth(Timestamp)` | +| `datetime_part("hour", ts)` | Extract component | `datetime_part("year", Timestamp)` | +| `format_datetime(ts, fmt)` | Format as string | `format_datetime(Timestamp, "yyyy-MM")` | +| `ago(1d)` | Relative time | `where Timestamp > ago(1d)` | +| `between(a .. b)` | Range filter | `where Timestamp between (datetime(2024-01-01) .. datetime(2024-01-31T23:59:59))` | + +## 12. Operator Naming & Equality + +### Equality Operators + +```kql +| where State == "TEXAS" // case-sensitive exact match +| where State =~ "texas" // case-insensitive +| where State != "TEXAS" // not equal +| where State !~ "texas" // case-insensitive not equal +``` + +### contains vs has + +```kql +// contains: substring match (slower) +| where Message contains "error" // finds "MyErrorHandler" too + +// has: term/word match (faster, uses index) +| where Message has "error" // word boundaries only +``` + +## 13. Error Recovery Strategy + +When a first KQL query fails, the correct response is almost always to +**fix the specific error**, not change strategy. + +### The Correct Pattern + +``` +Query 1: extract(@"pattern", 1, col) → Parse error (bad escaping) +Query 2: extract(@"pattern", 1, col) → Fix the specific issue → Success +``` + +**Rules**: + +1. Read the error message — it tells you exactly what's wrong +2. Fix the **specific** syntax/escaping issue, don't switch approaches +3. Use the self-correction table (Section 10) to map errors to fixes +4. Only switch approaches after 2 failed fixes of the same query + +## 14. Query Writing Checklist + +Before running any KQL query, mentally check: + +1. **Pre-filtered?** Large tables have `| where` before `| summarize` +2. **Result bounded?** Exploratory queries end with `| take N` or `| top N` +3. **Dynamic columns cast?** Dynamic columns in `by`/`on`/`order by` are wrapped +4. **Regex has groups?** `extract_all` patterns have `()` around captures +5. **Join cardinality safe?** Both sides checked with `dcount()` before joining +6. **Needed columns only?** Wide tables get `| project` to drop unneeded columns +7. **Datetime literals valid?** Using `datetime(2024-01-01)` not `datetime(2024)` +8. **Complex by-expressions?** Use `| extend` first, then `| summarize by` column +9. **Error recovery plan?** Fix the specific error — don't change strategy +10. **Right tool?** `kusto_query` for queries, `kusto_command` for management +11. **Checked the plan?** For expensive queries, use `kusto_show_queryplan` first + +## 15. Diagnostics & Query Optimization + +### Query Plan Analysis + +Use `kusto_show_queryplan` to plan a query without executing it: + +| Field | What it tells you | +| --------------------------------------------- | ------------------------------------------------- | +| `stats.PlanSize` | Overall plan complexity — compare two approaches | +| `execution_hints.estimated_rows` | Total rows expected — **strongest cost signal** | +| `execution_hints.shard_scans[].has_selection` | `true` = filter narrows scan; `false` = full scan | + +### Comparing Two Approaches + +Plan both, compare `estimated_rows` and `shard_scans`. Flag a rewrite as a +regression if `estimated_rows` increases >50%. + +### Cluster Diagnostics + +Use `kusto_diagnostics` before heavy workloads to check capacity: + +| Section | What it tells you | +| -------------------- | -------------------------------------------------------------- | +| `capacity` | Resource slots: Queries, Ingestions (Total/Consumed/Remaining) | +| `cluster` | Node count, cores, RAM | +| `principal_roles` | Your permissions per database | +| `ingestion_failures` | Failed ingestions in last 24h | diff --git a/skills/mcp-services/SKILL.md b/skills/mcp-services/SKILL.md index f97d721..6bb871f 100644 --- a/skills/mcp-services/SKILL.md +++ b/skills/mcp-services/SKILL.md @@ -10,12 +10,12 @@ triggers: - SharePoint - OneDrive - Copilot - - email - - meetings - - tasks - external service - mcp server - work-iq + - Microsoft 365 + - M365 + - Outlook antiPatterns: - Don't try to manage_plugin("mcp:") — MCP servers are NOT regular plugins - Don't import from "host:mcp-gateway" — that's the gateway sentinel, not a server diff --git a/skills/pdf-expert/SKILL.md b/skills/pdf-expert/SKILL.md index 19eb701..4a302ba 100644 --- a/skills/pdf-expert/SKILL.md +++ b/skills/pdf-expert/SKILL.md @@ -4,9 +4,6 @@ description: Expert at building professional PDF documents using Hyperlight sand triggers: - pdf - PDF - - document - - report - - paper - brochure - poster - resume @@ -15,6 +12,9 @@ triggers: - letter - manual - newsletter + - PDF report + - PDF document + - generate PDF patterns: - two-handler-pipeline - image-embed diff --git a/skills/report-builder/SKILL.md b/skills/report-builder/SKILL.md index 7f017b5..636254a 100644 --- a/skills/report-builder/SKILL.md +++ b/skills/report-builder/SKILL.md @@ -3,14 +3,14 @@ name: report-builder description: Generate documents, reports, and formatted output files triggers: - report - - document - DOCX - markdown - - generate - - summary - - output - - write - - create file + - write a report + - generate report + - status report + - executive summary + - markdown report + - DOCX report patterns: - two-handler-pipeline - file-generation diff --git a/skills/research-synthesiser/SKILL.md b/skills/research-synthesiser/SKILL.md index c719b37..0e32386 100644 --- a/skills/research-synthesiser/SKILL.md +++ b/skills/research-synthesiser/SKILL.md @@ -10,15 +10,12 @@ triggers: - competitive analysis - market research - deep dive - - investigate - - survey - comprehensive analysis - multi-source - cross-reference - state of the art - landscape - benchmark - - evaluate patterns: - fetch-and-process - data-extraction diff --git a/skills/web-scraper/SKILL.md b/skills/web-scraper/SKILL.md index 55292a3..cb417c5 100644 --- a/skills/web-scraper/SKILL.md +++ b/skills/web-scraper/SKILL.md @@ -3,14 +3,15 @@ name: web-scraper description: Extract data from web pages using fetch plugin and ha:html/ha:markdown triggers: - scrape - - extract - crawl - website - HTML - - parse - web page - webpage - URL + - scrape website + - web scraping + - crawl site patterns: - fetch-and-process - data-extraction diff --git a/src/agent/approach-resolver.ts b/src/agent/approach-resolver.ts index 2c1affc..255bf06 100644 --- a/src/agent/approach-resolver.ts +++ b/src/agent/approach-resolver.ts @@ -26,6 +26,10 @@ export interface MaterialisedGuidance { modules: string[]; /** host:* plugins to enable (union). */ plugins: string[]; + /** MCP server names required by matched skills (union). */ + requiredMcp: string[]; + /** MCP server availability status (populated by caller with runtime state). */ + mcpStatus: MCPServerStatus[]; /** Ordered implementation steps (concatenated from patterns). */ steps: string[]; /** Domain rules from skill guidance. */ @@ -34,6 +38,20 @@ export interface MaterialisedGuidance { antiPatterns: string[]; } +/** Runtime availability status for a required MCP server. */ +export interface MCPServerStatus { + /** Server name (e.g. "fabric-rti-mcp"). */ + name: string; + /** Whether the server is configured in MCP config. */ + configured: boolean; + /** Connection state if configured. */ + state?: "idle" | "connecting" | "connected" | "error" | "closed"; + /** Number of discovered tools (if connected). */ + toolCount?: number; + /** Error message (if state is "error"). */ + lastError?: string; +} + // ── Implementation ────────────────────────────────────────────────── /** @@ -79,6 +97,7 @@ export function resolveApproach( const profileSet = new Set(); const moduleSet = new Set(); const pluginSet = new Set(); + const mcpSet = new Set(); const config: Record = {}; const allSteps: string[] = []; const allRules: string[] = []; @@ -93,6 +112,11 @@ export function resolveApproach( antiPatternSet.add(ap); } + // Collect required MCP servers + for (const mcp of skill.requiresMcp) { + mcpSet.add(mcp); + } + // Extract rules from skill guidance const skillRules = extractRules(skill.guidance); allRules.push(...skillRules); @@ -137,6 +161,8 @@ export function resolveApproach( config, modules: [...moduleSet], plugins: [...pluginSet], + requiredMcp: [...mcpSet], + mcpStatus: [], steps: allSteps, rules: uniqueRules, antiPatterns: [...antiPatternSet], @@ -202,6 +228,8 @@ const GENERIC_GUIDANCE: MaterialisedGuidance = { config: {}, modules: [], plugins: [], + requiredMcp: [], + mcpStatus: [], steps: [ "1. Call list_modules to discover available modules", "2. Call module_info(name) for any relevant modules", @@ -243,6 +271,28 @@ export function formatGuidance(guidance: MaterialisedGuidance): string { `Plugins: ${guidance.plugins.join(", ")} — enable via manage_plugin or apply_profile`, ); } + if (guidance.mcpStatus.length > 0) { + parts.push("MCP Servers:"); + for (const s of guidance.mcpStatus) { + if (!s.configured) { + parts.push( + ` ❌ ${s.name} — not configured. Run: hyperagent --mcp setup-${s.name.replace(/^fabric-/, "")}`, + ); + } else if (s.state === "connected") { + parts.push( + ` ✅ ${s.name} — connected (${s.toolCount ?? 0} tools). Import from host:mcp-${s.name}`, + ); + } else if (s.state === "error") { + parts.push( + ` ⚠️ ${s.name} — configured but errored: ${s.lastError ?? "unknown"}`, + ); + } else { + parts.push( + ` ⚡ ${s.name} — configured (${s.state ?? "idle"}). Call manage_mcp({action:"connect", name:"${s.name}"}) to connect`, + ); + } + } + } if (guidance.profiles.length > 0) { parts.push(`Profiles: ${guidance.profiles.join(", ")}`); } diff --git a/src/agent/cli-parser.ts b/src/agent/cli-parser.ts index 82f4535..c15b84a 100644 --- a/src/agent/cli-parser.ts +++ b/src/agent/cli-parser.ts @@ -128,6 +128,7 @@ Standalone MCP setup commands (run and exit): --mcp-setup-filesystem [dir] Configure the filesystem MCP server (default: /tmp/mcp-fs) --mcp-show-config Show configured MCP servers --mcp-setup-workiq Configure Microsoft Work IQ stdio MCP server + --mcp-setup-fabric-rti [args...] Configure Fabric RTI (Kusto/KQL) MCP server --mcp-add-http [clientId] [tenantId] [scopes] [flow] Add a generic HTTP MCP server --mcp-m365-create-app [args...] Create/reuse Entra app for M365 HTTP MCP @@ -368,6 +369,13 @@ export function parseCliArgs( case "--mcp-setup-workiq": setMCPSetupCommand(config, { kind: "setup-workiq" }); break; + case "--mcp-setup-fabric-rti": + setMCPSetupCommand(config, { + kind: "setup-fabric-rti", + args: argv.slice(i + 1), + }); + i = argv.length; + break; case "--mcp-add-http": setMCPSetupCommand(config, { kind: "add-http", diff --git a/src/agent/hljs-kql.ts b/src/agent/hljs-kql.ts new file mode 100644 index 0000000..ad15a79 --- /dev/null +++ b/src/agent/hljs-kql.ts @@ -0,0 +1,387 @@ +// ── highlight.js KQL Language Grammar ──────────────────────────────── +// +// Provides syntax highlighting for KQL (Kusto Query Language) code +// blocks in terminal markdown output. Keyword lists derived from +// @kusto/monaco-kusto (MIT licensed) Monarch language definition. +// +// Registered as "kql" with aliases "kusto" and "csl". +// +// ───────────────────────────────────────────────────────────────────── + +/// + +// ── Keyword Lists (from @kusto/monaco-kusto Monarch definition) ───── + +const QUERY_OPERATORS = [ + "as", + "consume", + "count", + "distinct", + "evaluate", + "extend", + "facet", + "filter", + "find", + "fork", + "getschema", + "graph-match", + "graph-merge", + "graph-to-table", + "invoke", + "join", + "limit", + "lookup", + "make-graph", + "make-series", + "mv-apply", + "mv-expand", + "order", + "parse", + "parse-kv", + "parse-where", + "partition", + "print", + "project", + "project-away", + "project-keep", + "project-rename", + "project-reorder", + "range", + "reduce", + "render", + "sample", + "sample-distinct", + "scan", + "search", + "serialize", + "sort", + "summarize", + "take", + "top", + "top-hitters", + "top-nested", + "union", + "where", +]; + +const KEYWORDS = [ + "and", + "asc", + "between", + "by", + "contains", + "desc", + "false", + "from", + "has", + "in", + "inner", + "leftouter", + "let", + "not", + "on", + "or", + "set", + "step", + "table", + "to", + "true", + "with", + "datatable", + "materialize", +]; + +const TYPES = [ + "bool", + "datetime", + "decimal", + "double", + "dynamic", + "guid", + "int", + "long", + "real", + "string", + "timespan", +]; + +const SCALAR_FUNCTIONS = [ + "abs", + "acos", + "ago", + "array_concat", + "array_length", + "array_slice", + "array_split", + "asin", + "atan", + "atan2", + "base64_decode_tostring", + "base64_encode_tostring", + "bin", + "bin_at", + "case", + "ceiling", + "coalesce", + "cos", + "countof", + "datetime_add", + "datetime_diff", + "datetime_part", + "dayofmonth", + "dayofweek", + "dayofyear", + "endofday", + "endofmonth", + "endofweek", + "endofyear", + "exp", + "exp10", + "exp2", + "extract", + "extract_all", + "extractjson", + "floor", + "format_datetime", + "format_timespan", + "gamma", + "geo_distance_2points", + "geo_point_in_circle", + "geo_point_in_polygon", + "geo_point_to_geohash", + "geo_point_to_s2cell", + "getmonth", + "gettype", + "getyear", + "hash", + "hash_sha256", + "iif", + "indexof", + "isempty", + "isfinite", + "isinf", + "isnan", + "isnotempty", + "isnotnull", + "isnull", + "log", + "log10", + "log2", + "make_datetime", + "make_string", + "make_timespan", + "max_of", + "min_of", + "monthofyear", + "next", + "now", + "pack", + "pack_array", + "parse_csv", + "parse_ipv4", + "parse_json", + "parse_path", + "parse_url", + "parse_urlquery", + "parse_xml", + "pow", + "prev", + "radians", + "rand", + "repeat", + "replace_regex", + "replace_string", + "reverse", + "round", + "row_cumsum", + "row_number", + "row_window_session", + "sign", + "sin", + "split", + "sqrt", + "startofday", + "startofmonth", + "startofweek", + "startofyear", + "strcat", + "strcat_array", + "strcat_delim", + "strcmp", + "strlen", + "strrep", + "substring", + "tan", + "tobool", + "todatetime", + "todecimal", + "todouble", + "todynamic", + "toguid", + "tohex", + "toint", + "tolong", + "tolower", + "toreal", + "toscalar", + "tostring", + "totimespan", + "toupper", + "translate", + "trim", + "trim_end", + "trim_start", + "url_decode", + "url_encode", + "week_of_year", +]; + +const AGGREGATION_FUNCTIONS = [ + "any", + "avg", + "avgif", + "count", + "countif", + "dcount", + "dcount_hll", + "hll", + "hll_merge", + "make_bag", + "make_list", + "make_set", + "max", + "maxif", + "min", + "minif", + "percentile", + "percentiles", + "stdev", + "stdevif", + "sum", + "sumif", + "tdigest", + "tdigest_merge", + "variance", + "varianceif", +]; + +const SERIES_FUNCTIONS = [ + "series_add", + "series_cosine_similarity", + "series_decompose", + "series_decompose_anomalies", + "series_decompose_forecast", + "series_divide", + "series_equals", + "series_fill_backward", + "series_fill_const", + "series_fill_forward", + "series_fill_linear", + "series_fir", + "series_fit_2lines", + "series_fit_line", + "series_greater", + "series_greater_equals", + "series_iir", + "series_less", + "series_less_equals", + "series_multiply", + "series_not_equals", + "series_outliers", + "series_pearson_correlation", + "series_periods_detect", + "series_periods_validate", + "series_seasonal", + "series_stats", + "series_stats_dynamic", + "series_subtract", +]; + +// ── Grammar Definition ────────────────────────────────────────────── + +/** + * highlight.js language definition for KQL (Kusto Query Language). + * + * Register with: `hljs.registerLanguage("kql", kqlLanguage)` + */ +export default function kqlLanguage(hljs?: HLJSApi): Language { + // All built-in functions combined for the "built_in" className + const ALL_FUNCTIONS = [ + ...SCALAR_FUNCTIONS, + ...AGGREGATION_FUNCTIONS, + ...SERIES_FUNCTIONS, + ].join(" "); + + // Comments: // line comments + const LINE_COMMENT: Mode = hljs!.COMMENT("//", "$"); + + // Strings: double-quoted and single-quoted + const STRING: Mode = { + className: "string", + variants: [ + { begin: '"', end: '"', contains: [{ begin: '\\\\"' }] }, + { begin: "'", end: "'", contains: [{ begin: "\\\\'" }] }, + // Multi-line string literals: h@"...", @"..." + { begin: /[hH]?@"/, end: '"' }, + ], + }; + + // Numbers: integers, decimals, scientific notation, timespan suffixes + const NUMBER: Mode = { + className: "number", + variants: [ + // Timespan literals: 1d, 2h, 30m, 45s, 100ms, 500tick + { begin: /\b\d+(\.\d+)?(d|h|m|s|ms|tick)\b/ }, + // Scientific notation + { begin: /\b\d+(\.\d+)?[eE][+-]?\d+\b/ }, + // Decimal + { begin: /\b\d+\.\d+\b/ }, + // Integer (including hex 0x...) + { begin: /\b0x[0-9a-fA-F]+\b/ }, + { begin: /\b\d+\b/ }, + ], + }; + + // Management commands: .show, .create, .alter, .drop, etc. + const COMMAND: Mode = { + className: "meta", + begin: + /\.(show|create|alter|drop|set|append|delete|rename|move|replace|ingest|export|create-or-alter|alter-merge|create-merge|set-or-append|set-or-replace|drop-pretend)\b/, + }; + + // datetime() and timespan() literals + const DATETIME_LITERAL: Mode = { + className: "literal", + begin: /\b(datetime|timespan)\s*\(/, + end: /\)/, + contains: [ + { + className: "string", + begin: /[^)]+/, + }, + ], + }; + + return { + name: "Kusto Query Language", + aliases: ["kusto", "csl"], + case_insensitive: true, + keywords: { + keyword: [...QUERY_OPERATORS, ...KEYWORDS].join(" "), + type: TYPES.join(" "), + built_in: ALL_FUNCTIONS, + }, + contains: [ + LINE_COMMENT, + STRING, + NUMBER, + COMMAND, + DATETIME_LITERAL, + // Pipe operator — visually distinct + { + className: "punctuation", + begin: /\|/, + }, + ], + }; +} diff --git a/src/agent/index.ts b/src/agent/index.ts index a45319a..bfdb5bd 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -101,7 +101,11 @@ import { } from "./format-exports.js"; import { loadPatterns } from "./pattern-loader.js"; import { loadSkills } from "./skill-loader.js"; -import { runSuggestApproach } from "./approach-resolver.js"; +import { + runSuggestApproach, + formatGuidance, + type MCPServerStatus, +} from "./approach-resolver.js"; import { validatePath } from "../../plugins/shared/path-jail.js"; import { validateJavaScript as validateJavaScriptGuest, @@ -5746,6 +5750,27 @@ function buildSessionConfig() { debugLog, ); + // Enrich with MCP server availability from the running manager + if (result.guidance.requiredMcp.length > 0 && mcpManager) { + const servers = mcpManager.listServers(); + const serverMap = new Map(servers.map((s) => [s.name, s])); + for (const name of result.guidance.requiredMcp) { + const conn = serverMap.get(name); + const status: MCPServerStatus = conn + ? { + name, + configured: true, + state: conn.state, + toolCount: conn.tools.length, + lastError: conn.lastError, + } + : { name, configured: false }; + result.guidance.mcpStatus.push(status); + } + // Re-format guidance now that mcpStatus is populated + result.formatted = formatGuidance(result.guidance); + } + state.lastGuidance = result.formatted; // UI feedback — one-liner so the user knows what matched diff --git a/src/agent/markdown-renderer.ts b/src/agent/markdown-renderer.ts index 43b4e84..d2ed105 100644 --- a/src/agent/markdown-renderer.ts +++ b/src/agent/markdown-renderer.ts @@ -10,7 +10,20 @@ import { marked, type MarkedOptions } from "marked"; import TerminalRenderer from "marked-terminal"; +import { createRequire } from "node:module"; import { resolve } from "node:path"; +import kqlLanguage from "./hljs-kql.js"; + +// Register KQL/Kusto syntax highlighting on the shared highlight.js instance. +// cli-highlight (used by marked-terminal) loads highlight.js via CJS require(). +// We use createRequire to get the same CJS singleton so registration is visible +// to cli-highlight's highlight() calls. +// Grammar derived from @kusto/monaco-kusto (MIT) Monarch definition. +const cjsRequire = createRequire(import.meta.url); +const hljsInstance = cjsRequire("highlight.js") as { + registerLanguage: Function; +}; +hljsInstance.registerLanguage("kql", kqlLanguage); // Configure marked with the terminal renderer once at import time. // marked-terminal handles: headings, bold/italic, code blocks with diff --git a/src/agent/mcp/setup-commands.ts b/src/agent/mcp/setup-commands.ts index fcb3ab2..1ad23c0 100644 --- a/src/agent/mcp/setup-commands.ts +++ b/src/agent/mcp/setup-commands.ts @@ -22,6 +22,7 @@ export type MCPSetupCommand = | { kind: "setup-filesystem"; dir: string } | { kind: "show-config" } | { kind: "setup-workiq" } + | { kind: "setup-fabric-rti"; args: string[] } | { kind: "add-http"; args: string[] } | { kind: "m365-create-app"; args: string[] } | { kind: "m365-setup"; args: string[] } @@ -242,6 +243,9 @@ export function runMCPSetupCommand( case "setup-workiq": setupWorkIQ(); return; + case "setup-fabric-rti": + setupFabricRTI(command.args); + return; case "add-http": addHttp(command.args); return; @@ -278,6 +282,65 @@ function setupEverything(): void { console.log(" Start HyperAgent and ask for the everything test tools."); } +// ── Fabric RTI (Kusto/KQL) MCP server ──────────────────────────────── + +const FABRIC_RTI_DEFAULT_URI = "https://help.kusto.windows.net/"; +const FABRIC_RTI_DEFAULT_DB = "Samples"; + +function setupFabricRTI(args: string[]): void { + console.log("Configuring MCP 'fabric-rti-mcp' server (Kusto/KQL)..."); + console.log( + "Requires Python 3.10+ and uvx (or uv). Uses Azure Identity for auth.", + ); + + // Check uvx availability + const uvxPath = spawnCapture("which", ["uvx"]); + if (!uvxPath) { + logWarning( + "uvx not found. Install: curl -LsSf https://astral.sh/uv/install.sh | sh", + ); + console.log(" Or: pip install uv"); + console.log(" Then re-run this command."); + process.exit(1); + } + + // Parse optional args: --cluster-uri --database + let clusterUri = FABRIC_RTI_DEFAULT_URI; + let defaultDb = FABRIC_RTI_DEFAULT_DB; + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case "--cluster-uri": + clusterUri = args[++i] ?? FABRIC_RTI_DEFAULT_URI; + break; + case "--database": + defaultDb = args[++i] ?? FABRIC_RTI_DEFAULT_DB; + break; + } + } + + const cfg = readConfig(); + cfg.mcpServers = cfg.mcpServers ?? {}; + cfg.mcpServers["fabric-rti-mcp"] = { + command: "uvx", + args: ["microsoft-fabric-rti-mcp"], + env: { + KUSTO_SERVICE_URI: clusterUri, + KUSTO_SERVICE_DEFAULT_DB: defaultDb, + }, + }; + writeConfig(cfg); + + logSuccess(`MCP 'fabric-rti-mcp' server configured in ${CONFIG_FILE}`); + console.log(` Cluster URI: ${clusterUri}`); + console.log(` Default DB: ${defaultDb}`); + console.log(""); + console.log(" Auth: Uses Azure Identity (az login, VS Code, etc.)"); + console.log(" Override defaults with: --cluster-uri --database "); + console.log( + " Start HyperAgent and ask a KQL question to trigger the kql-expert skill.", + ); +} + function setupGithub(): void { console.log("Configuring MCP 'github' server..."); console.log("Requires npm/npx and a GitHub token in GITHUB_TOKEN."); diff --git a/src/agent/skill-loader.ts b/src/agent/skill-loader.ts index c3e8d2b..0002d85 100644 --- a/src/agent/skill-loader.ts +++ b/src/agent/skill-loader.ts @@ -28,6 +28,8 @@ export interface Skill { patterns: string[]; /** Things the LLM must NOT do. */ antiPatterns: string[]; + /** MCP server names this skill requires (e.g. ["fabric-rti-mcp"]). */ + requiresMcp: string[]; /** The markdown body — domain-specific guidance text. */ guidance: string; } @@ -155,6 +157,9 @@ export function loadSkills(dir: string): Map { const antiPatterns = Array.isArray(meta.antiPatterns) ? (meta.antiPatterns as string[]) : []; + const requiresMcp = Array.isArray(meta["requires-mcp"]) + ? (meta["requires-mcp"] as string[]) + : []; skills.set(name, { name, @@ -162,6 +167,7 @@ export function loadSkills(dir: string): Map { triggers, patterns, antiPatterns, + requiresMcp, guidance: body, }); } diff --git a/tests/approach-resolver.test.ts b/tests/approach-resolver.test.ts index 35179a9..c769159 100644 --- a/tests/approach-resolver.test.ts +++ b/tests/approach-resolver.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect } from "vitest"; import { resolveApproach, + formatGuidance, type MaterialisedGuidance, + type MCPServerStatus, } from "../src/agent/approach-resolver.js"; import type { Skill } from "../src/agent/skill-loader.js"; import type { Pattern } from "../src/agent/pattern-loader.js"; @@ -18,6 +20,7 @@ function makeSkill( triggers: [], patterns, antiPatterns, + requiresMcp: [], guidance, }; } @@ -173,4 +176,131 @@ describe("approach-resolver", () => { const result = resolveApproach(["s1"], skills, patterns); expect(result.steps[0]).toBe("[p1] Do thing"); }); + + it("should collect requiredMcp from skills", () => { + const skill: Skill = { + name: "kql", + description: "", + triggers: [], + patterns: [], + antiPatterns: [], + requiresMcp: ["fabric-rti-mcp"], + guidance: "", + }; + const skills = new Map([["kql", skill]]); + const patterns = new Map(); + + const result = resolveApproach(["kql"], skills, patterns); + expect(result.requiredMcp).toEqual(["fabric-rti-mcp"]); + // mcpStatus starts empty (populated by caller) + expect(result.mcpStatus).toEqual([]); + }); + + it("should union requiredMcp across multiple skills", () => { + const s1: Skill = { + name: "s1", + description: "", + triggers: [], + patterns: [], + antiPatterns: [], + requiresMcp: ["fabric-rti-mcp"], + guidance: "", + }; + const s2: Skill = { + name: "s2", + description: "", + triggers: [], + patterns: [], + antiPatterns: [], + requiresMcp: ["fabric-rti-mcp", "other-mcp"], + guidance: "", + }; + const skills = new Map([ + ["s1", s1], + ["s2", s2], + ]); + const patterns = new Map(); + + const result = resolveApproach(["s1", "s2"], skills, patterns); + expect(result.requiredMcp.sort()).toEqual(["fabric-rti-mcp", "other-mcp"]); + }); + + it("should return empty requiredMcp when skills have none", () => { + const skills = new Map([["s1", makeSkill("s1", ["p1"])]]); + const patterns = new Map([["p1", makePattern("p1")]]); + + const result = resolveApproach(["s1"], skills, patterns); + expect(result.requiredMcp).toEqual([]); + }); +}); + +describe("formatGuidance — MCP Servers section", () => { + function makeGuidance( + overrides: Partial = {}, + ): MaterialisedGuidance { + return { + matchedSkills: [], + modules: [], + plugins: [], + profiles: [], + config: {}, + steps: [], + rules: [], + antiPatterns: [], + requiredMcp: [], + mcpStatus: [], + ...overrides, + }; + } + + it("should show ❌ for unconfigured MCP server", () => { + const status: MCPServerStatus = { + name: "fabric-rti-mcp", + configured: false, + }; + const output = formatGuidance(makeGuidance({ mcpStatus: [status] })); + expect(output).toContain("MCP Servers:"); + expect(output).toContain("❌ fabric-rti-mcp — not configured"); + expect(output).toContain("hyperagent --mcp setup-rti-mcp"); + }); + + it("should show ✅ for connected MCP server", () => { + const status: MCPServerStatus = { + name: "fabric-rti-mcp", + configured: true, + state: "connected", + toolCount: 13, + }; + const output = formatGuidance(makeGuidance({ mcpStatus: [status] })); + expect(output).toContain("✅ fabric-rti-mcp — connected (13 tools)"); + expect(output).toContain("host:mcp-fabric-rti-mcp"); + }); + + it("should show ⚠️ for errored MCP server", () => { + const status: MCPServerStatus = { + name: "fabric-rti-mcp", + configured: true, + state: "error", + lastError: "auth failed", + }; + const output = formatGuidance(makeGuidance({ mcpStatus: [status] })); + expect(output).toContain("⚠️ fabric-rti-mcp — configured but errored"); + expect(output).toContain("auth failed"); + }); + + it("should show ⚡ for idle/configured MCP server", () => { + const status: MCPServerStatus = { + name: "fabric-rti-mcp", + configured: true, + state: "idle", + }; + const output = formatGuidance(makeGuidance({ mcpStatus: [status] })); + expect(output).toContain("⚡ fabric-rti-mcp — configured (idle)"); + expect(output).toContain('manage_mcp({action:"connect"'); + }); + + it("should omit MCP section when mcpStatus is empty", () => { + const output = formatGuidance(makeGuidance()); + expect(output).not.toContain("MCP Servers:"); + }); }); diff --git a/tests/intent-matcher.test.ts b/tests/intent-matcher.test.ts index 92afe41..b4c58fb 100644 --- a/tests/intent-matcher.test.ts +++ b/tests/intent-matcher.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from "vitest"; import { matchIntent, type SkillMatch } from "../src/agent/intent-matcher.js"; import type { Skill } from "../src/agent/skill-loader.js"; +import { loadSkills } from "../src/agent/skill-loader.js"; +import { join } from "path"; function makeSkill( name: string, @@ -13,6 +15,7 @@ function makeSkill( triggers, patterns, antiPatterns: [], + requiresMcp: [], guidance: "", }; } @@ -126,3 +129,187 @@ describe("intent-matcher", () => { expect(matches[0].score).toBeGreaterThanOrEqual(2); }); }); + +// ── Real-world intent matching against loaded skills ──────────────── +// +// These tests use the actual skill definitions from skills/ to ensure +// that prompts match the correct skill. This catches regressions from +// accidentally adding overly-generic triggers. +// +// ───────────────────────────────────────────────────────────────────── + +const SKILLS_DIR = join(import.meta.dirname, "..", "skills"); +const realSkills = loadSkills(SKILLS_DIR); + +/** + * Assert that the given prompt matches the expected skill as the top result. + * If expectedSkill is null, asserts no skill matches at all. + */ +function expectTopMatch(prompt: string, expectedSkill: string | null): void { + const matches = matchIntent(prompt, realSkills); + if (expectedSkill === null) { + expect( + matches.length, + `Expected no match for: "${prompt}" but got: ${matches.map((m) => m.name).join(", ")}`, + ).toBe(0); + } else { + expect( + matches.length, + `Expected "${expectedSkill}" for: "${prompt}" but got no matches`, + ).toBeGreaterThan(0); + expect( + matches[0].name, + `Expected "${expectedSkill}" but got "${matches[0].name}" (triggers: ${matches[0].matchedTriggers.join(", ")}) for: "${prompt}"`, + ).toBe(expectedSkill); + } +} + +describe("intent-matcher — real-world skill matching", () => { + describe("kql-expert", () => { + it.each([ + "Query my ADX cluster for failed requests in the last 24 hours", + "Write a KQL query to detect anomalies in telemetry data", + "Analyze Application Insights logs for error spikes", + "Use Kusto to find the top 10 users by request count", + "Show me the Eventhouse table schema", + ])("should match: %s", (prompt) => { + expectTopMatch(prompt, "kql-expert"); + }); + }); + + describe("data-processor", () => { + it.each([ + "Convert this CSV to JSON", + "Parse the CSV file and transform the data", + "Build an ETL pipeline for tabular data", + ])("should match: %s", (prompt) => { + expectTopMatch(prompt, "data-processor"); + }); + }); + + describe("api-explorer", () => { + it.each([ + "Test the REST API endpoint for user creation", + "Check the swagger docs for the GraphQL API", + ])("should match: %s", (prompt) => { + expectTopMatch(prompt, "api-explorer"); + }); + }); + + describe("pdf-expert", () => { + it.each([ + "Create a PDF invoice from this data", + "Generate a PDF report with charts", + ])("should match: %s", (prompt) => { + expectTopMatch(prompt, "pdf-expert"); + }); + }); + + describe("report-builder", () => { + it.each([ + "Write a report on Q3 sales performance", + "Generate a DOCX executive summary", + ])("should match: %s", (prompt) => { + expectTopMatch(prompt, "report-builder"); + }); + }); + + describe("pptx-expert", () => { + it.each([ + "Create a PowerPoint presentation about AI", + "Build a slide deck for the board meeting", + ])("should match: %s", (prompt) => { + expectTopMatch(prompt, "pptx-expert"); + }); + }); + + describe("web-scraper", () => { + it.each([ + "Scrape the website for product prices", + "Crawl this URL and extract the article text", + ])("should match: %s", (prompt) => { + expectTopMatch(prompt, "web-scraper"); + }); + }); + + describe("mcp-services", () => { + it.each([ + "Check my Teams messages from today", + "Search SharePoint for the project plan", + ])("should match: %s", (prompt) => { + expectTopMatch(prompt, "mcp-services"); + }); + }); + + describe("research-synthesiser", () => { + it.each(["Do a deep dive competitive analysis of cloud providers"])( + "should match: %s", + (prompt) => { + expectTopMatch(prompt, "research-synthesiser"); + }, + ); + }); + + describe("xlsx-expert", () => { + it.each(["Create an Excel spreadsheet with pivot tables"])( + "should match: %s", + (prompt) => { + expectTopMatch(prompt, "xlsx-expert"); + }, + ); + }); + + describe("no false positives from generic terms", () => { + it("'sort the results by name' should not match data-processor", () => { + const matches = matchIntent("sort the results by name", realSkills); + const dp = matches.find((m) => m.name === "data-processor"); + expect( + dp, + "data-processor should not match generic 'sort' — triggers should be specific", + ).toBeUndefined(); + }); + + it("'analyze this data and filter it' should not match data-processor", () => { + const matches = matchIntent( + "analyze this data and filter it", + realSkills, + ); + const dp = matches.find((m) => m.name === "data-processor"); + expect( + dp, + "data-processor should not match generic 'analyze'/'data'/'filter'", + ).toBeUndefined(); + }); + + it("'parse the error message' should not match web-scraper", () => { + const matches = matchIntent("parse the error message", realSkills); + const ws = matches.find((m) => m.name === "web-scraper"); + expect( + ws, + "web-scraper should not match generic 'parse'", + ).toBeUndefined(); + }); + + it("'write a summary document' should not match report-builder", () => { + const matches = matchIntent("write a summary document", realSkills); + const rb = matches.find((m) => m.name === "report-builder"); + expect( + rb, + "report-builder should not match generic 'write'/'summary'/'document'", + ).toBeUndefined(); + }); + + it("'send an email about the meeting tasks' should match mcp-services via Mail", () => { + // "email" contains "mail" which matches the M365 Mail trigger — this is correct behaviour + const matches = matchIntent( + "send an email about the meeting tasks", + realSkills, + ); + const mcp = matches.find((m) => m.name === "mcp-services"); + expect( + mcp, + "mcp-services should match 'email' via the Mail trigger", + ).toBeDefined(); + }); + }); +}); diff --git a/tests/skill-loader.test.ts b/tests/skill-loader.test.ts new file mode 100644 index 0000000..d16f695 --- /dev/null +++ b/tests/skill-loader.test.ts @@ -0,0 +1,184 @@ +// ── Skill Loader Tests ────────────────────────────────────────────── +// +// Validates that loadSkills() correctly parses SKILL.md frontmatter, +// including the requires-mcp field. +// +// ───────────────────────────────────────────────────────────────────── + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { loadSkills } from "../src/agent/skill-loader.js"; + +let tempDir: string; + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "skill-loader-test-")); +}); + +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); +}); + +/** Write a SKILL.md file inside tempDir//SKILL.md */ +function writeSkill(name: string, content: string): void { + const dir = join(tempDir, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "SKILL.md"), content, "utf-8"); +} + +describe("skill-loader", () => { + describe("requires-mcp parsing", () => { + it("should parse requires-mcp as string array", () => { + writeSkill( + "kql-expert", + `--- +name: kql-expert +description: KQL expertise +triggers: + - KQL + - Kusto +requires-mcp: + - fabric-rti-mcp +--- + +# KQL Expert +Some guidance text. +`, + ); + + const skills = loadSkills(tempDir); + const skill = skills.get("kql-expert"); + expect(skill).toBeDefined(); + expect(skill!.requiresMcp).toEqual(["fabric-rti-mcp"]); + }); + + it("should handle multiple requires-mcp entries", () => { + writeSkill( + "multi-mcp", + `--- +name: multi-mcp +description: Needs multiple MCP servers +requires-mcp: + - fabric-rti-mcp + - github-mcp + - other-server +--- + +# Multi MCP Skill +`, + ); + + const skills = loadSkills(tempDir); + const skill = skills.get("multi-mcp"); + expect(skill).toBeDefined(); + expect(skill!.requiresMcp).toEqual([ + "fabric-rti-mcp", + "github-mcp", + "other-server", + ]); + }); + + it("should default to empty array when requires-mcp is absent", () => { + writeSkill( + "no-mcp", + `--- +name: no-mcp +description: No MCP needed +triggers: + - something +--- + +# No MCP +`, + ); + + const skills = loadSkills(tempDir); + const skill = skills.get("no-mcp"); + expect(skill).toBeDefined(); + expect(skill!.requiresMcp).toEqual([]); + }); + + it("should parse inline array format for requires-mcp", () => { + writeSkill( + "inline-mcp", + `--- +name: inline-mcp +description: Inline array +requires-mcp: [fabric-rti-mcp, github-mcp] +--- + +# Inline +`, + ); + + const skills = loadSkills(tempDir); + const skill = skills.get("inline-mcp"); + expect(skill).toBeDefined(); + expect(skill!.requiresMcp).toEqual(["fabric-rti-mcp", "github-mcp"]); + }); + }); + + describe("basic frontmatter parsing", () => { + it("should parse name, description, triggers, and patterns", () => { + writeSkill( + "test-skill", + `--- +name: test-skill +description: A test skill +triggers: + - alpha + - beta +patterns: + - fetch-and-process +antiPatterns: + - Don't do bad things +--- + +# Test Skill +Guidance body here. +`, + ); + + const skills = loadSkills(tempDir); + const skill = skills.get("test-skill"); + expect(skill).toBeDefined(); + expect(skill!.name).toBe("test-skill"); + expect(skill!.description).toBe("A test skill"); + expect(skill!.triggers).toEqual(["alpha", "beta"]); + expect(skill!.patterns).toEqual(["fetch-and-process"]); + expect(skill!.antiPatterns).toEqual(["Don't do bad things"]); + expect(skill!.guidance).toContain("# Test Skill"); + expect(skill!.guidance).toContain("Guidance body here."); + }); + + it("should use directory name when name field is absent", () => { + writeSkill( + "dir-name", + `--- +description: Unnamed skill +--- + +# Body +`, + ); + + const skills = loadSkills(tempDir); + const skill = skills.get("dir-name"); + expect(skill).toBeDefined(); + expect(skill!.name).toBe("dir-name"); + }); + + it("should return empty map for nonexistent directory", () => { + const skills = loadSkills("/nonexistent/path/that/does/not/exist"); + expect(skills.size).toBe(0); + }); + + it("should skip directories without SKILL.md", () => { + mkdirSync(join(tempDir, "empty-dir"), { recursive: true }); + const skills = loadSkills(tempDir); + expect(skills.size).toBe(0); + }); + }); +}); From dcf3e30374cc7fff09f9582065bb196c9c49888a Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Thu, 14 May 2026 19:28:33 +0100 Subject: [PATCH 2/4] fix: gate diagnostic output behind --verbose/--debug flags - Rust: gate [hyperlight-analysis] eprintln! behind HYPERAGENT_VERBOSE/DEBUG env vars - MCP: gate [mcp] Connected/Auth success messages behind isVerbose() - MCP: pipe subprocess stderr when not verbose to suppress Python INFO logs - KQL skill: remove azuremcpserver reference, add anti-pattern to use fabric-rti-mcp only Signed-off-by: Simon Davies --- skills/kql-expert/SKILL.md | 3 +- src/agent/mcp/client-manager.ts | 29 +++++++++++++++++--- src/code-validator/guest/host/src/runtime.rs | 29 ++++++++++++++++---- 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/skills/kql-expert/SKILL.md b/skills/kql-expert/SKILL.md index 7c263a0..4801dd2 100644 --- a/skills/kql-expert/SKILL.md +++ b/skills/kql-expert/SKILL.md @@ -1,6 +1,6 @@ --- name: kql-expert -description: KQL language expertise for writing correct, efficient Kusto queries using Fabric RTI MCP or Azure MCP kusto tools +description: KQL language expertise for writing correct, efficient Kusto queries using Fabric RTI MCP tools triggers: - KQL - Kusto @@ -49,6 +49,7 @@ antiPatterns: - Don't call kusto_query for management commands — use kusto_command for .show/.create/.alter - Don't hardcode MCP tool schemas — call mcp_tool_info first - Don't call MCP tools directly — execute them inside registered handler code + - Don't use azuremcpserver for KQL — always use fabric-rti-mcp which has dedicated Kusto tools requires-mcp: - fabric-rti-mcp allowed-tools: diff --git a/src/agent/mcp/client-manager.ts b/src/agent/mcp/client-manager.ts index d20f330..cf3278b 100644 --- a/src/agent/mcp/client-manager.ts +++ b/src/agent/mcp/client-manager.ts @@ -178,7 +178,9 @@ export function createMCPClientManager() { } else { // Interactive: acquire token eagerly (silent → browser/device-code). await acquireMsalToken(name, authConfig); - console.error(`[mcp] ✅ Authentication successful.`); + if (isVerbose()) { + console.error(`[mcp] ✅ Authentication successful.`); + } } // Build a provider that serves cached tokens to the MCP transport. @@ -282,9 +284,11 @@ export function createMCPClientManager() { saveCachedSession(name, sessionId); } - console.error( - `[mcp] Connected to "${name}" — ${sanitised.length} tool(s) available`, - ); + if (isVerbose()) { + console.error( + `[mcp] Connected to "${name}" — ${sanitised.length} tool(s) available`, + ); + } return conn; } @@ -463,10 +467,16 @@ function createTransport(config: MCPServerConfig, serverName?: string): any { ...(config.env ?? {}), }; + // When not in verbose/debug mode, pipe subprocess stderr so MCP server + // log output (e.g. Python INFO lines) doesn't leak to the terminal. + // In verbose mode, inherit so the user sees everything. + const stderr = isVerbose() ? "inherit" : "pipe"; + return new StdioClientTransport({ command: config.command, args: config.args, env, + stderr, }); } @@ -678,3 +688,14 @@ export function extractEmbeddedJson(text: string): unknown { return text; } + +/** + * Check whether verbose/debug output is enabled. + * Returns true when HYPERAGENT_VERBOSE=1 or HYPERAGENT_DEBUG=1. + */ +function isVerbose(): boolean { + return ( + process.env.HYPERAGENT_VERBOSE === "1" || + process.env.HYPERAGENT_DEBUG === "1" + ); +} diff --git a/src/code-validator/guest/host/src/runtime.rs b/src/code-validator/guest/host/src/runtime.rs index 5dfa5d1..60d0046 100644 --- a/src/code-validator/guest/host/src/runtime.rs +++ b/src/code-validator/guest/host/src/runtime.rs @@ -40,6 +40,17 @@ const MAX_BLOCKING_THREADS: usize = 8; /// Timeout for graceful runtime shutdown. const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); +/// Check whether verbose/debug output is enabled via env vars. +/// Returns true if HYPERAGENT_VERBOSE=1 or HYPERAGENT_DEBUG=1. +fn is_verbose() -> bool { + std::env::var("HYPERAGENT_VERBOSE") + .map(|v| v == "1") + .unwrap_or(false) + || std::env::var("HYPERAGENT_DEBUG") + .map(|v| v == "1") + .unwrap_or(false) +} + /// Shared Tokio runtime for all analysis operations. /// /// Uses `OnceLock>>` instead of `LazyLock>` @@ -62,10 +73,12 @@ fn init_runtime() -> Mutex> { .build() { Ok(rt) => { - eprintln!( - "[hyperlight-analysis] Initialized runtime with {} workers", - workers - ); + if is_verbose() { + eprintln!( + "[hyperlight-analysis] Initialized runtime with {} workers", + workers + ); + } Mutex::new(Some(rt)) } Err(e) => { @@ -98,8 +111,12 @@ pub(crate) fn shutdown_runtime() { && let Ok(mut guard) = mutex.lock() && let Some(rt) = guard.take() { - eprintln!("[hyperlight-analysis] Shutting down runtime..."); + if is_verbose() { + eprintln!("[hyperlight-analysis] Shutting down runtime..."); + } rt.shutdown_timeout(SHUTDOWN_TIMEOUT); - eprintln!("[hyperlight-analysis] Runtime shutdown complete"); + if is_verbose() { + eprintln!("[hyperlight-analysis] Runtime shutdown complete"); + } } } From 767c174c372225ec919ec9d4207858fff1729d14 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Thu, 14 May 2026 19:48:55 +0100 Subject: [PATCH 3/4] docs: update skill tables and add requires-mcp documentation - Add all 9 skills to tables in docs/SKILLS.md, README.md, skills/CLAUDE.md, and .github/instructions/skills.instructions.md (was missing kql-expert, xlsx-expert, mcp-services in various files) - Document requires-mcp frontmatter field in docs/SKILLS.md - Add requires-mcp to YAML examples in instruction files - Alphabetise skill tables for consistency Signed-off-by: Simon Davies --- .github/instructions/skills.instructions.md | 13 +++++--- README.md | 1 + docs/SKILLS.md | 37 ++++++++++++++++----- skills/CLAUDE.md | 13 +++++--- 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/.github/instructions/skills.instructions.md b/.github/instructions/skills.instructions.md index 127a3f1..44bbe11 100644 --- a/.github/instructions/skills.instructions.md +++ b/.github/instructions/skills.instructions.md @@ -28,6 +28,8 @@ antiPatterns: - "Don't do X" allowed-tools: - tool_name +requires-mcp: + - mcp-server-name --- # Skill Title @@ -39,13 +41,16 @@ Detailed instructions for the LLM when this skill is active. | Skill | Purpose | |-------|---------| -| `pptx-expert` | PowerPoint presentation building | +| `api-explorer` | API discovery, testing, and documentation | +| `data-processor` | Data processing workflows | +| `kql-expert` | KQL/Kusto queries via Fabric RTI MCP | +| `mcp-services` | External MCP server integration | | `pdf-expert` | PDF document building | +| `pptx-expert` | PowerPoint presentation building | +| `report-builder` | Report and document generation | | `research-synthesiser` | Research and synthesis | -| `data-processor` | Data processing workflows | | `web-scraper` | Web scraping | -| `report-builder` | Report generation | -| `api-explorer` | API exploration | +| `xlsx-expert` | Excel workbook generation | ## Triggers diff --git a/README.md b/README.md index 1955e09..209e9ec 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,7 @@ Useful skills include: | `pptx-expert` | Professional PowerPoint decks | | `pdf-expert` | Structured PDF reports | | `xlsx-expert` | Excel workbook generation | +| `kql-expert` | Kusto queries via Fabric RTI MCP tools | | `report-builder` | Multi-format reports and document output | | `data-processor` | Data cleaning, joins, aggregation, and export | | `api-explorer` | Understanding and calling APIs | diff --git a/docs/SKILLS.md b/docs/SKILLS.md index cf31a6e..35a991e 100644 --- a/docs/SKILLS.md +++ b/docs/SKILLS.md @@ -38,12 +38,16 @@ You: /skill pptx-expert | Skill | Description | |-------|-------------| -| `pptx-expert` | Building professional PowerPoint presentations | -| `web-scraper` | Extracting data from web pages | -| `research-synthesiser` | Combining multiple sources into reports | -| `data-processor` | Transforming and analyzing data | -| `report-builder` | Creating structured reports | -| `api-explorer` | Discovering and using APIs | +| `api-explorer` | Discover, test, and document REST/GraphQL/JSON APIs | +| `data-processor` | Transform, filter, and analyse data using sandbox handlers | +| `kql-expert` | KQL expertise for Kusto queries via Fabric RTI MCP tools | +| `mcp-services` | Connect and use external MCP servers (M365, GitHub, custom) | +| `pdf-expert` | Professional PDF documents using sandbox modules | +| `pptx-expert` | Professional PowerPoint presentations using sandbox modules | +| `report-builder` | Generate documents, reports, and formatted output | +| `research-synthesiser` | Multi-source research synthesised into structured reports | +| `web-scraper` | Extract data from web pages using fetch plugin | +| `xlsx-expert` | Excel XLSX workbooks using sandbox modules | ## Skill File Format @@ -66,6 +70,8 @@ antiPatterns: allowed-tools: - register_handler - execute_javascript +requires-mcp: + - mcp-server-name --- ``` @@ -104,6 +110,18 @@ and always produce high-quality Z. | `patterns` | No | Code patterns relevant to this skill | | `antiPatterns` | No | Common mistakes to avoid | | `allowed-tools` | No | Tools the LLM can use with this skill | +| `requires-mcp` | No | MCP server names that must be connected for this skill | + +### MCP Server Dependencies + +If `requires-mcp` is specified, the skill declares which MCP servers it needs. The approach resolver checks whether required servers are connected and shows their status: + +```yaml +requires-mcp: + - fabric-rti-mcp +``` + +When the skill is matched, the agent enriches the guidance with MCP connection status so the LLM knows whether to prompt the user to connect the server first. ### Tool Restrictions @@ -218,9 +236,10 @@ Skills are discovered automatically from: List available skills: ``` You: /skill list - 📚 Available skills (6): - pptx-expert - Expert at building professional PowerPoint presentations - web-scraper - Extracting data from web pages + 📚 Available skills (9): + api-explorer - Discover, test, and document REST/GraphQL/JSON APIs + data-processor - Transform, filter, and analyse data + kql-expert - KQL expertise for Kusto queries via Fabric RTI MCP ... ``` diff --git a/skills/CLAUDE.md b/skills/CLAUDE.md index b644d40..e67e619 100644 --- a/skills/CLAUDE.md +++ b/skills/CLAUDE.md @@ -24,6 +24,8 @@ antiPatterns: - "Don't do X" allowed-tools: - tool_name +requires-mcp: + - mcp-server-name --- # Skill Title @@ -35,13 +37,16 @@ Detailed instructions for the LLM when this skill is active. | Skill | Purpose | |-------|---------| -| `pptx-expert` | PowerPoint presentation building | +| `api-explorer` | API discovery, testing, and documentation | +| `data-processor` | Data processing workflows | +| `kql-expert` | KQL/Kusto queries via Fabric RTI MCP | +| `mcp-services` | External MCP server integration | | `pdf-expert` | PDF document building | +| `pptx-expert` | PowerPoint presentation building | +| `report-builder` | Report and document generation | | `research-synthesiser` | Research and synthesis | -| `data-processor` | Data processing workflows | | `web-scraper` | Web scraping | -| `report-builder` | Report generation | -| `api-explorer` | API exploration | +| `xlsx-expert` | Excel workbook generation | ## Triggers From 15c663faef420c3b1edf146c87b5830a42ba1257 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Thu, 14 May 2026 22:03:08 +0100 Subject: [PATCH 4/4] fix: address PR #137 review feedback (9 issues) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix #2: Use MCP_SETUP_COMMANDS lookup for correct CLI setup hints (was generating 'setup-rti-mcp' instead of '--mcp-setup-fabric-rti') - Fix #3: Show unconfigured status when mcpManager is null - Fix #4: Update test assertion to match corrected setup command - Fix #5: Add highlight.js ^10.7.3 as explicit dependency (was transitive) - Fix #6: Validate Fabric RTI options — reject missing values & unknown flags - Fix #7: Set HYPERAGENT_VERBOSE=1 from cli.verbose flag - Fix #8: Update skill count from 9 to 10 in docs/SKILLS.md - Fix #9: Use stderr 'ignore' instead of 'pipe' to avoid back-pressure - Fix #10: Add CLI parser test for --mcp-setup-fabric-rti flag Comment #1 (hljs types) is invalid — typecheck passes with /// . Signed-off-by: Simon Davies --- docs/SKILLS.md | 2 +- package-lock.json | 1 + package.json | 1 + src/agent/approach-resolver.ts | 10 ++++++++- src/agent/index.ts | 38 ++++++++++++++++++++------------- src/agent/mcp/client-manager.ts | 5 +++-- src/agent/mcp/setup-commands.ts | 18 ++++++++++++++-- tests/approach-resolver.test.ts | 2 +- tests/tune.test.ts | 24 +++++++++++++++++++++ 9 files changed, 79 insertions(+), 22 deletions(-) diff --git a/docs/SKILLS.md b/docs/SKILLS.md index 35a991e..aae636b 100644 --- a/docs/SKILLS.md +++ b/docs/SKILLS.md @@ -236,7 +236,7 @@ Skills are discovered automatically from: List available skills: ``` You: /skill list - 📚 Available skills (9): + 📚 Available skills (10): api-explorer - Discover, test, and document REST/GraphQL/JSON APIs data-processor - Transform, filter, and analyse data kql-expert - KQL expertise for Kusto queries via Fabric RTI MCP diff --git a/package-lock.json b/package-lock.json index 712aeab..645bb9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@hyperlight/js-host-api": "file:deps/js-host-api", "@modelcontextprotocol/sdk": "^1.29.0", "boxen": "^8.0.1", + "highlight.js": "^10.7.3", "hyperlight-analysis": "file:src/code-validator/guest", "marked": "^15.0.12", "marked-terminal": "^7.3.0", diff --git a/package.json b/package.json index 25897d0..af53f47 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@hyperlight/js-host-api": "file:deps/js-host-api", "@modelcontextprotocol/sdk": "^1.29.0", "boxen": "^8.0.1", + "highlight.js": "^10.7.3", "hyperlight-analysis": "file:src/code-validator/guest", "marked": "^15.0.12", "marked-terminal": "^7.3.0", diff --git a/src/agent/approach-resolver.ts b/src/agent/approach-resolver.ts index 255bf06..d989ca0 100644 --- a/src/agent/approach-resolver.ts +++ b/src/agent/approach-resolver.ts @@ -12,6 +12,13 @@ import { loadSkills } from "./skill-loader.js"; import { loadPatterns } from "./pattern-loader.js"; import { loadModule, type ModuleHints } from "./module-store.js"; +// ── MCP server name → CLI setup command mapping ────────────────────── +// Maps an MCP server name (as declared in skills) to the CLI flag that +// configures it. Used by formatGuidance() to show actionable hints. +const MCP_SETUP_COMMANDS: Record = { + "fabric-rti-mcp": "--mcp-setup-fabric-rti", +}; + // ── Types ──────────────────────────────────────────────────────────── /** Materialised guidance returned by suggest_approach. */ @@ -275,8 +282,9 @@ export function formatGuidance(guidance: MaterialisedGuidance): string { parts.push("MCP Servers:"); for (const s of guidance.mcpStatus) { if (!s.configured) { + const setupFlag = MCP_SETUP_COMMANDS[s.name] ?? `--mcp-setup-${s.name}`; parts.push( - ` ❌ ${s.name} — not configured. Run: hyperagent --mcp setup-${s.name.replace(/^fabric-/, "")}`, + ` ❌ ${s.name} — not configured. Run: hyperagent ${setupFlag}`, ); } else if (s.state === "connected") { parts.push( diff --git a/src/agent/index.ts b/src/agent/index.ts index bfdb5bd..5dfcc8f 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -285,6 +285,7 @@ if (cli.profile) { // configure_sandbox at runtime for buffer changes. } +if (cli.verbose) process.env.HYPERAGENT_VERBOSE = "1"; if (cli.debug) process.env.HYPERAGENT_DEBUG = "1"; // Conditionally allow the tuning tool through the gate @@ -5751,21 +5752,28 @@ function buildSessionConfig() { ); // Enrich with MCP server availability from the running manager - if (result.guidance.requiredMcp.length > 0 && mcpManager) { - const servers = mcpManager.listServers(); - const serverMap = new Map(servers.map((s) => [s.name, s])); - for (const name of result.guidance.requiredMcp) { - const conn = serverMap.get(name); - const status: MCPServerStatus = conn - ? { - name, - configured: true, - state: conn.state, - toolCount: conn.tools.length, - lastError: conn.lastError, - } - : { name, configured: false }; - result.guidance.mcpStatus.push(status); + if (result.guidance.requiredMcp.length > 0) { + if (mcpManager) { + const servers = mcpManager.listServers(); + const serverMap = new Map(servers.map((s) => [s.name, s])); + for (const name of result.guidance.requiredMcp) { + const conn = serverMap.get(name); + const status: MCPServerStatus = conn + ? { + name, + configured: true, + state: conn.state, + toolCount: conn.tools.length, + lastError: conn.lastError, + } + : { name, configured: false }; + result.guidance.mcpStatus.push(status); + } + } else { + // No MCP manager — mark all required servers as unconfigured + for (const name of result.guidance.requiredMcp) { + result.guidance.mcpStatus.push({ name, configured: false }); + } } // Re-format guidance now that mcpStatus is populated result.formatted = formatGuidance(result.guidance); diff --git a/src/agent/mcp/client-manager.ts b/src/agent/mcp/client-manager.ts index cf3278b..d5ce238 100644 --- a/src/agent/mcp/client-manager.ts +++ b/src/agent/mcp/client-manager.ts @@ -467,10 +467,11 @@ function createTransport(config: MCPServerConfig, serverName?: string): any { ...(config.env ?? {}), }; - // When not in verbose/debug mode, pipe subprocess stderr so MCP server + // When not in verbose/debug mode, discard subprocess stderr so MCP server // log output (e.g. Python INFO lines) doesn't leak to the terminal. + // Using "ignore" avoids back-pressure from an un-drained pipe buffer. // In verbose mode, inherit so the user sees everything. - const stderr = isVerbose() ? "inherit" : "pipe"; + const stderr = isVerbose() ? "inherit" : "ignore"; return new StdioClientTransport({ command: config.command, diff --git a/src/agent/mcp/setup-commands.ts b/src/agent/mcp/setup-commands.ts index 1ad23c0..a8897a5 100644 --- a/src/agent/mcp/setup-commands.ts +++ b/src/agent/mcp/setup-commands.ts @@ -310,11 +310,25 @@ function setupFabricRTI(args: string[]): void { for (let i = 0; i < args.length; i++) { switch (args[i]) { case "--cluster-uri": - clusterUri = args[++i] ?? FABRIC_RTI_DEFAULT_URI; + if (i + 1 >= args.length || args[i + 1].startsWith("--")) { + console.error("Error: --cluster-uri requires a value."); + process.exit(1); + } + clusterUri = args[++i]; break; case "--database": - defaultDb = args[++i] ?? FABRIC_RTI_DEFAULT_DB; + if (i + 1 >= args.length || args[i + 1].startsWith("--")) { + console.error("Error: --database requires a value."); + process.exit(1); + } + defaultDb = args[++i]; break; + default: + console.error(`Error: unknown option '${args[i]}'.`); + console.error( + "Usage: hyperagent --mcp-setup-fabric-rti [--cluster-uri ] [--database ]", + ); + process.exit(1); } } diff --git a/tests/approach-resolver.test.ts b/tests/approach-resolver.test.ts index c769159..9de4bf8 100644 --- a/tests/approach-resolver.test.ts +++ b/tests/approach-resolver.test.ts @@ -261,7 +261,7 @@ describe("formatGuidance — MCP Servers section", () => { const output = formatGuidance(makeGuidance({ mcpStatus: [status] })); expect(output).toContain("MCP Servers:"); expect(output).toContain("❌ fabric-rti-mcp — not configured"); - expect(output).toContain("hyperagent --mcp setup-rti-mcp"); + expect(output).toContain("hyperagent --mcp-setup-fabric-rti"); }); it("should show ✅ for connected MCP server", () => { diff --git a/tests/tune.test.ts b/tests/tune.test.ts index 34b0ac4..80cb2f1 100644 --- a/tests/tune.test.ts +++ b/tests/tune.test.ts @@ -133,6 +133,30 @@ describe("MCP setup CLI flags", () => { }); }); + it("parses fabric-rti setup with default and explicit args", () => { + expect(parseCliArgs(["--mcp-setup-fabric-rti"]).mcpSetupCommand).toEqual({ + kind: "setup-fabric-rti", + args: [], + }); + expect( + parseCliArgs([ + "--mcp-setup-fabric-rti", + "--cluster-uri", + "https://my.kusto.windows.net", + "--database", + "MyDb", + ]).mcpSetupCommand, + ).toEqual({ + kind: "setup-fabric-rti", + args: [ + "--cluster-uri", + "https://my.kusto.windows.net", + "--database", + "MyDb", + ], + }); + }); + it("captures remaining args for setup helpers with pass-through options", () => { expect( parseCliArgs([