diff --git a/CHANGELOG.md b/CHANGELOG.md index 91c0724..e8a2995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.0] - 2026-03-08 + +### Added +- Added `docs/capability-taxonomy-design.md`, a draft design for evolving capability matching with stable canonical buckets, dynamic internal facets, and adapter-specific projection rules for downstream gateways. +- Added rich namespace classification diagnostics: canonical capability, internal facet tags, and evidence metadata are now available to internal callers, with `status --verbose` surfacing classification and optional adapter-projection details. +- Added additive config support for `operations.dynamicToolSurface.facetOverrides` and `operations.adapterProjection` so downstream integrations can remap canonical MCP² capabilities into adapter-specific buckets without changing the public router names. +- Added a canonical `database` capability for database tooling such as Prisma and Supabase, including heuristic signals, semantic reference text, and public capability metadata. +- Added a canonical `observability` capability for monitoring and error-tracking tooling such as Sentry, including heuristic signals, semantic reference text, and public capability metadata. +- Added a canonical `messaging` capability for chat and notification tooling such as Slack, including heuristic signals, semantic reference text, and public capability metadata. +- Added a canonical `payments` capability for billing and checkout tooling such as Stripe, including heuristic signals, semantic reference text, and public capability metadata. +- Added a canonical `design_workspace` capability for structured design-as-code tooling such as Pencil, including public capability metadata and docs/examples that distinguish it from generic visual `design` tools. +- Added a live-captured official Figma MCP fixture test and tuned `design_workspace` signals so Figma's published MCP tool surface classifies as `design_workspace` rather than generic visual `design`. + +### Changed +- Updated `docs/ARCHITECTURE.md` to document the planned direction for taxonomy evolution: preserve stable public capability IDs and layer richer internal classification beneath them. +- Capability inference now records richer internal signals while preserving the existing public capability router contract. + ## [0.7.0] - 2026-03-06 ### Added diff --git a/README.md b/README.md index fd29e7e..e67a416 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,24 @@ refresh = "on_connect" [operations.dynamicToolSurface.capabilityOverrides] # Optional explicit namespace -> capability pinning. # auggie = "code_search" +# pencil = "design_workspace" + +[operations.dynamicToolSurface.facetOverrides] +# Optional namespace -> internal facet tags for diagnostics/adapter projection. +# pencil = ["design_workspace", "design_tokens", "design_to_code"] + +[operations.adapterProjection] +enabled = false +defaultAdapter = "mcp2" + +[operations.adapterProjection.adapters.gateway] +mode = "projected" +fallbackBucket = "general" + +[operations.adapterProjection.adapters.gateway.namespaceBucketOverrides] +# Optional adapter-specific remapping for namespaces that should not inherit the +# downstream bucket semantics of their canonical MCP² capability. +# pencil = "general" ``` For existing configs created before this default, run: @@ -196,6 +214,9 @@ Migration note: capability-router mode is now the only public surface. `mcp-squa Legacy keys `operations.dynamicToolSurface.mode` and `operations.dynamicToolSurface.naming` are accepted for compatibility, ignored at runtime, and warned on load. `mcp-squared migrate` removes them. +`mcp-squared status --verbose` now also reports canonical namespace classification, +internal facet tags, and optional adapter-projection results when configured. + ## Tool API (Capability Routers) MCP² exposes one public tool per non-empty capability at connect time: @@ -203,8 +224,13 @@ MCP² exposes one public tool per non-empty capability at connect time: - `docs` - `browser_automation` - `issue_tracking` +- `observability` +- `messaging` +- `payments` +- `database` - `cms_content` - `design` +- `design_workspace` - `hosting_deploy` - `time_util` - `research` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 25ec720..031911d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -66,3 +66,14 @@ graph TD - A monitor server (UDS/TCP) exposes real-time stats for the TUI monitor. - Stats include request counts, latency, memory usage, index size, and embedding/co-occurrence counts. + +## Taxonomy Evolution + +The public router surface intentionally uses stable canonical capability IDs because +security policy matching, config overrides, and client tool calls all depend on +predictable `capability:action` contracts. Richer internal classification and +adapter-specific bucket mappings should be layered underneath that public API +rather than replacing it with runtime-generated categories. + +See `docs/capability-taxonomy-design.md` for the proposed canonical capability + +facet + adapter-projection model. diff --git a/docs/capability-taxonomy-design.md b/docs/capability-taxonomy-design.md new file mode 100644 index 0000000..72c28b2 --- /dev/null +++ b/docs/capability-taxonomy-design.md @@ -0,0 +1,364 @@ +# Capability Taxonomy and Adapter Projection Design + +Status: Draft +Last updated: 2026-03-07 +Owners: MCP² maintainers + +## Problem Statement + +MCP² currently assigns each upstream namespace to exactly one public capability bucket such as `code_search`, `docs`, or `design`. + +That model is stable and easy to route, but it breaks down in two cases: + +- Some upstreams span multiple concerns. +- Different downstream consumers mean different things by the same label. + +Example: Pencil is a reasonable fit for MCP²'s current `design` bucket, but it is a poor fit for a downstream gateway whose `design` bucket mostly means screenshot analysis, OCR, diagram understanding, and visual diffing. + +The current system needs: + +- Stable public capability IDs for policy, routing, and client contracts. +- Richer internal metadata than one label. +- A way to project MCP²'s canonical model into adapter-specific buckets without rewriting the core taxonomy for every integration. + +## Goals + +- Keep the public MCP² capability contract stable and versionable. +- Add richer internal classification signals without exposing unstable tool names. +- Support adapter-specific projections for external gateways and tool ecosystems. +- Preserve existing `capability:action` security semantics. +- Make misclassifications diagnosable with explicit evidence. + +## Non-Goals + +- Dynamically generating new public capability tools at runtime. +- Replacing capability routing with per-upstream tool exposure. +- Solving all taxonomy gaps in one release. +- Introducing a mandatory breaking change for existing configs. + +## Decision + +MCP² should keep a curated, pre-set canonical capability taxonomy for its public API. + +It should add two new internal layers underneath that API: + +1. Capability facets: dynamic secondary tags that describe what a namespace actually does. +2. Adapter projections: mapping rules that translate canonical capability + facets into another consumer's bucket model. + +In short: + +- Public buckets are fixed. +- Internal facets are dynamic. +- External bucket mappings are adapter-specific. + +## Why Not Dynamic Public Categories? + +Dynamic public categories would destabilize several existing contracts: + +- Security rules match `capability:action`. +- Config overrides pin `namespace -> capability`. +- MCP clients call one public tool per capability. +- Router naming, docs, and confirmation tokens all depend on stable capability IDs. + +Those surfaces should remain versioned and predictable. + +## Proposed Data Model + +### 1. Canonical Capability + +This stays close to the current `CapabilityId` model. + +```ts +export type CanonicalCapabilityId = + | "code_search" + | "docs" + | "browser_automation" + | "issue_tracking" + | "observability" + | "messaging" + | "payments" + | "database" + | "cms_content" + | "design" + | "design_workspace" + | "ai_media_generation" + | "hosting_deploy" + | "time_util" + | "research" + | "general"; +``` + +This remains the only public MCP² router namespace unless a future release explicitly adds a new canonical capability. + +### 2. Capability Facets + +Facets are internal, many-valued tags. They are not public tool names. + +```ts +export type CapabilityFacetId = string; +``` + +Examples: + +- `vision_analysis` +- `ocr` +- `diagram_understanding` +- `ui_diff` +- `design_workspace` +- `layout_analysis` +- `design_tokens` +- `design_to_code` +- `database_admin` +- `observability` +- `payments` + +Facets can be model-generated, heuristic, or explicitly overridden. + +### 3. Classification Result + +The current single-label output becomes a richer internal record. + +```ts +export interface NamespaceClassification { + namespace: string; + canonicalCapability: CanonicalCapabilityId; + confidence: number; + runnerUp?: { + canonicalCapability: CanonicalCapabilityId; + confidence: number; + }; + facets: CapabilityFacetId[]; + evidence: ClassificationEvidence[]; +} + +export interface ClassificationEvidence { + source: + | "namespace_hint" + | "tool_signal" + | "semantic_similarity" + | "user_override" + | "adapter_override"; + target: string; + score?: number; + note?: string; +} +``` + +### 4. Adapter Projection + +Adapters consume canonical classification and decide how to expose it to a specific external surface. + +```ts +export interface AdapterProjectionResult { + adapterId: string; + bucket: string; + confidence: number; + reason: string; +} + +export interface AdapterCapabilityProfile { + id: string; + title: string; + summary: string; + acceptsCanonical: CanonicalCapabilityId[]; + prefersFacets?: CapabilityFacetId[]; + rejectsFacets?: CapabilityFacetId[]; +} +``` + +## Proposed Config Shape + +This is intentionally additive and non-breaking. + +```toml +[operations.dynamicToolSurface] +inference = "hybrid" +refresh = "on_connect" +semanticConfidenceThreshold = 0.45 + +[operations.dynamicToolSurface.capabilityOverrides] +# Existing stable override surface stays canonical. +# pencil = "design_workspace" + +[operations.dynamicToolSurface.facetOverrides] +pencil = ["design_workspace", "layout_analysis", "design_tokens", "design_to_code"] + +[operations.adapterProjection] +enabled = true +defaultAdapter = "mcp2" + +[operations.adapterProjection.adapters.mcp2] +mode = "canonical" + +[operations.adapterProjection.adapters.gateway] +mode = "projected" +fallbackBucket = "general" + +[[operations.adapterProjection.adapters.gateway.capabilities]] +id = "design" +title = "Design Analysis" +summary = "Analyze screenshots, diagrams, UI diffs, and other visual artifacts." +acceptsCanonical = ["design", "design_workspace", "browser_automation", "research"] +prefersFacets = ["vision_analysis", "ocr", "diagram_understanding", "ui_diff"] +rejectsFacets = ["design_workspace", "design_tokens", "design_to_code"] + +[[operations.adapterProjection.adapters.gateway.capabilities]] +id = "general" +title = "General" +summary = "Fallback bucket for tools that do not map cleanly." +acceptsCanonical = ["general", "design", "docs", "research"] + +[operations.adapterProjection.adapters.gateway.namespaceBucketOverrides] +pencil = "general" +``` + +## Runtime Behavior + +### Canonical MCP² Surface + +MCP² continues to expose one public tool per canonical capability. + +- Policies still match `capability:action`. +- Confirmation tokens remain scoped to canonical capability + action. +- Existing configs continue to work. + +### Internal Classification + +Classification becomes a 2-step process: + +1. Infer canonical capability. +2. Infer zero or more facets. + +Canonical capability is still required even when facets are present. + +### Adapter Projection + +Adapter projection is optional and happens after canonical classification. + +Examples: + +- Native MCP² client: + - Uses canonical capability directly. +- Gateway with screenshot-analysis `design` semantics: + - Uses adapter projection rules. +- Status/diagnostics output: + - Can show canonical capability plus inferred facets. + +## Pencil Example + +### Canonical Classification + +Pencil should remain canonically classified as: + +```ts +canonicalCapability = "design_workspace" +``` + +### Suggested Facets + +```ts +facets = [ + "design_workspace", + "layout_analysis", + "design_tokens", + "design_to_code", +]; +``` + +### Gateway Projection + +If the gateway's `design` bucket means screenshot/vision analysis, Pencil should not project there by default. + +A reasonable projection would be: + +```ts +adapter = "gateway" +bucket = "general" +``` + +Reason: + +- Pencil edits structured design workspaces. +- It is not primarily an OCR/vision-analysis/screenshot-diff tool. +- Mapping it to gateway `design` would over-promise the wrong affordances. + +## API Surface Changes + +### No Immediate Breaking Change + +Existing APIs continue to return canonical capability routers. + +### Optional New Diagnostics + +Future diagnostics may expose richer classification details: + +```json +{ + "namespace": "pencil", + "canonicalCapability": "design_workspace", + "facets": [ + "design_workspace", + "layout_analysis", + "design_tokens", + "design_to_code" + ], + "projection": { + "adapter": "gateway", + "bucket": "general" + } +} +``` + +This should be exposed only in internal status, verbose diagnostics, or explicit adapter tooling, not in the base MCP² public contract. + +## Migration Plan + +### Phase 1: Internal Types and Diagnostics + +- Add `NamespaceClassification`. +- Keep existing `CapabilityId` public contract unchanged. +- Add facet inference and evidence recording. +- Update `status --verbose` to show canonical capability, facets, and override source. + +### Phase 2: Adapter Projection Layer + +- Add adapter projection config and runtime mapping helpers. +- Keep MCP² default adapter in canonical mode. +- Add integration tests for adapter-specific projections. + +### Phase 3: Taxonomy Expansion + +Further canonical taxonomy changes should still be deliberate versioned changes, not emergent runtime behavior. + +## Test Strategy + +- Keep existing canonical capability tests. +- Add facet inference tests for multi-concern namespaces. +- Add adapter projection tests that validate external mappings without changing canonical routing. +- Add regression tests for known mismatches such as: + - Pencil vs gateway `design` + - shadcn vs visual-design tools + - Supabase vs hosting/DB projections + +## Consequences + +### Benefits + +- Preserves stable public tool names. +- Improves classification expressiveness. +- Makes integration mismatches explainable. +- Avoids forcing one taxonomy onto every adapter. + +### Costs + +- More internal state to maintain. +- Additional configuration surface. +- Need to define facet vocabularies with reasonable discipline. + +## Recommendation + +Adopt canonical capabilities + dynamic facets + adapter projection. + +Do not generate public categories dynamically. + +That keeps MCP² stable where it must be stable, and flexible where integrations actually need flexibility. diff --git a/package.json b/package.json index 28056fe..3c0232e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-squared", - "version": "0.7.0", + "version": "0.8.0", "description": "MCP² (Mercury Control Plane) - A local-first meta-server and proxy for the Model Context Protocol", "author": "aditzel", "license": "Apache-2.0", diff --git a/src/capabilities/inference.ts b/src/capabilities/inference.ts index 1982d79..db51554 100644 --- a/src/capabilities/inference.ts +++ b/src/capabilities/inference.ts @@ -15,8 +15,13 @@ export const CAPABILITY_IDS = [ "docs", "browser_automation", "issue_tracking", + "observability", + "messaging", + "payments", + "database", "cms_content", "design", + "design_workspace", "ai_media_generation", "hosting_deploy", "time_util", @@ -27,6 +32,65 @@ export const CAPABILITY_IDS = [ /** Capability identifier union. */ export type CapabilityId = (typeof CAPABILITY_IDS)[number]; +/** Secondary internal classification tags. */ +export type CapabilityFacetId = string; + +/** Source of the canonical capability decision. */ +export type CapabilityClassificationSource = + | "heuristic" + | "semantic" + | "user_override" + | "computed_override"; + +/** Override-only sources that can be injected from config/runtime callers. */ +export type CapabilityOverrideSource = Extract< + CapabilityClassificationSource, + "user_override" | "computed_override" +>; + +/** Evidence source used for diagnostics. */ +export type ClassificationEvidenceSource = + | "namespace_hint" + | "tool_signal" + | "semantic_similarity" + | "user_override" + | "computed_override" + | "facet_override"; + +/** A single diagnostic clue used during classification. */ +export interface ClassificationEvidence { + source: ClassificationEvidenceSource; + target: string; + score?: number; + note?: string; +} + +/** Secondary ranked canonical capability for diagnostics. */ +export interface NamespaceClassificationRunnerUp { + canonicalCapability: CapabilityId; + confidence: number; +} + +/** Rich namespace classification used for diagnostics and adapter projection. */ +export interface NamespaceClassification { + namespace: string; + canonicalCapability: CapabilityId; + capabilitySource: CapabilityClassificationSource; + confidence: number; + runnerUp?: NamespaceClassificationRunnerUp | undefined; + facets: CapabilityFacetId[]; + evidence: ClassificationEvidence[]; +} + +/** Options for rich namespace classification. */ +export interface NamespaceClassificationOptions { + capabilityOverrides?: Partial> | undefined; + capabilityOverrideSources?: + | Partial> + | undefined; + facetOverrides?: Partial> | undefined; +} + /** Minimal tool metadata used by inference heuristics. */ export interface NamespaceToolMetadata { name: string; @@ -52,7 +116,12 @@ const CAPABILITY_PRIORITY: CapabilityId[] = [ "docs", "browser_automation", "issue_tracking", + "observability", + "messaging", + "payments", + "database", "cms_content", + "design_workspace", "design", "ai_media_generation", "hosting_deploy", @@ -86,14 +155,44 @@ const NAMESPACE_HINTS: Array<{ pattern: /(linear|jira|issue|ticket|project|milestone)/i, score: 20, }, + { + capability: "observability", + pattern: + /(sentry|datadog|newrelic|grafana|honeycomb|bugsnag|rollbar|incident|alert|trace|metric|observability|monitor|(?:^|[._/\-\s])log(?:$|[._/\-\s]))/i, + score: 22, + }, + { + capability: "messaging", + pattern: + /(slack|discord|teams|telegram|twilio|message|chat|channel|notification|email|inbox|thread|(?:^|[._/\-\s])dm(?:$|[._/\-\s]))/i, + score: 22, + }, + { + capability: "payments", + pattern: + /(stripe|payment|invoice|subscription|checkout|billing|refund|charge|customer portal)/i, + score: 22, + }, + { + capability: "database", + pattern: + /(prisma|supabase|postgres(?:ql)?|mysql|sqlite|mongodb|redis|neon|planetscale|hasura|database|sql)/i, + score: 22, + }, { capability: "cms_content", pattern: /(sanity|content|cms|dataset|schema|studio)/i, score: 20, }, + { + capability: "design_workspace", + pattern: /(pencil|figma|figjam|penfile|design[-_ ]workspace)/i, + score: 24, + }, { capability: "design", - pattern: /(pencil|figma|ui|design|artifact|visual)/i, + pattern: + /(sketch|design|artifact|visual|(?:^|[._/\-\s])ui(?:$|[._/\-\s]))/i, score: 20, }, { @@ -163,6 +262,65 @@ const CAPABILITY_PATTERNS: Record = { /\bcomment(?:s)?\b/i, /\blinear\b/i, ], + observability: [ + /\berror(?:s)?\b/i, + /\bincident(?:s)?\b/i, + /\balert(?:s)?\b/i, + /\btrace(?:s)?\b/i, + /\bmetric(?:s)?\b/i, + /\blogs?\b/i, + /\bmonitor(?:ing)?\b/i, + /\bperformance\b/i, + /\bexception(?:s)?\b/i, + /\bcrash(?:es)?\b/i, + /\bsentry\b/i, + /\brollbar\b/i, + /\bdatadog\b/i, + /\bgrafana\b/i, + ], + messaging: [ + /\bmessage(?:s)?\b/i, + /\bchannel(?:s)?\b/i, + /\bchat\b/i, + /\bthread(?:s)?\b/i, + /\bdm\b/i, + /\bnotification(?:s)?\b/i, + /\bemail\b/i, + /\binbox\b/i, + /\bslack\b/i, + /\bdiscord\b/i, + /\btelegram\b/i, + /\bteams\b/i, + /\btwilio\b/i, + ], + payments: [ + /\bpayment(?:s)?\b/i, + /\binvoice(?:s)?\b/i, + /\bsubscription(?:s)?\b/i, + /\bcheckout\b/i, + /\bbilling\b/i, + /\brefund(?:s)?\b/i, + /\bcharge(?:s)?\b/i, + /\bcustomer portal\b/i, + /\bstripe\b/i, + ], + database: [ + /\bdatabase\b/i, + /\bsql\b/i, + /\bquery\b/i, + /\bqueries\b/i, + /\btable(?:s)?\b/i, + /\bcolumn(?:s)?\b/i, + /\brow(?:s)?\b/i, + /\bschema\b/i, + /\bmigration\b/i, + /\borm\b/i, + /\bpostgres(?:ql)?\b/i, + /\bmysql\b/i, + /\bsqlite\b/i, + /\bprisma\b/i, + /\bsupabase\b/i, + ], cms_content: [ /\bcms\b/i, /\bcontent\b/i, @@ -174,6 +332,32 @@ const CAPABILITY_PATTERNS: Record = { /\bsanity\b/i, /\bmigration\b/i, ], + design_workspace: [ + /\b\.pen\b/i, + /\bbatch_design\b/i, + /\bbatch_get\b/i, + /\bget_design_context\b/i, + /\bdesign context\b/i, + /\bget_variable_defs\b/i, + /\bvariable defs\b/i, + /\bcode connect\b/i, + /\bget_code_connect_map\b/i, + /\badd_code_connect_map\b/i, + /\bget_code_connect_suggestions\b/i, + /\bsend_code_connect_mappings\b/i, + /\bget_figjam\b/i, + /\bgenerate_diagram\b/i, + /\bget_metadata\b/i, + /\bnode ids?\b/i, + /\blayer ids?\b/i, + /\bselection\b/i, + /\beditor[_ ]state\b/i, + /\bcanvas\b/i, + /\bdesign system\b/i, + /\bdesign tokens\b/i, + /\bsync\b.*\bcss\b/i, + /\bexport\b.*\breact\b/i, + ], design: [ /\bdesign\b/i, /\bui\b/i, @@ -234,6 +418,105 @@ const CAPABILITY_PATTERNS: Record = { general: [], }; +const FACET_PATTERNS: Record = { + vision_analysis: [ + /\banaly[sz]e[_ ]image\b/i, + /\banaly[sz]e[_ ]video\b/i, + /\bdiagnos(?:e|ing)?[_ ]error[_ ]screenshot\b/i, + /\bextract[_ ]text[_ ]from[_ ]screenshot\b/i, + /\bvisual diff/i, + ], + ocr: [/\bocr\b/i, /\bextract[_ ]text\b/i, /\btext extraction\b/i], + diagram_understanding: [ + /\bdiagram\b/i, + /\bflowchart\b/i, + /\buml\b/i, + /\ber diagram\b/i, + /\barchitecture diagram\b/i, + ], + ui_diff: [ + /\bui[_ ]diff\b/i, + /\bvisual differences?\b/i, + /\bcompare before\/after\b/i, + ], + design_workspace: [ + /\bpencil\b/i, + /\bfigma\b/i, + /\bfigjam\b/i, + /\b\.pen\b/i, + /\bbatch_design\b/i, + /\bbatch_get\b/i, + /\bdesign context\b/i, + /\bselection\b/i, + /\bnode ids?\b/i, + /\blayer ids?\b/i, + /\bmetadata\b/i, + /\beditor[_ ]state\b/i, + /\bdesign elements?\b/i, + /\bcanvas\b/i, + ], + layout_analysis: [ + /\blayout\b/i, + /\boverlap(?:ping)?\b/i, + /\bposition(?:ing)?\b/i, + /\bhierarchy\b/i, + /\bspacing\b/i, + ], + design_tokens: [ + /\bvariables?\b/i, + /\btokens?\b/i, + /\btheme\b/i, + /\bcolor palette\b/i, + /\btypography scale\b/i, + /\bcss variables?\b/i, + ], + design_to_code: [ + /\bexport\b.*\bcomponent\b/i, + /\bgenerate\b.*\b(?:react|vue|svelte|next\.js|typescript)\b/i, + /\bsync\b.*\bcode\b/i, + /\bimport\b.*\bcodebase\b/i, + /\bcode connect\b/i, + /\bdesign system rules?\b/i, + /\btailwind\b/i, + /\bshadcn\b/i, + ], + database_admin: [ + /\bdatabase\b/i, + /\bsql\b/i, + /\btable\b/i, + /\bmigration\b/i, + /\bschema\b/i, + /\bprisma\b/i, + /\bsupabase\b/i, + ], + observability: [ + /\bsentry\b/i, + /\berror tracking\b/i, + /\btrace(?:s)?\b/i, + /\bmetric(?:s)?\b/i, + /\blogs?\b/i, + /\balert(?:s)?\b/i, + /\bincident(?:s)?\b/i, + /\bmonitor(?:ing)?\b/i, + ], + messaging: [ + /\bslack\b/i, + /\bmessage(?:s)?\b/i, + /\bchannel(?:s)?\b/i, + /\bchat\b/i, + /\bnotification(?:s)?\b/i, + /\bemail\b/i, + ], + payments: [ + /\bstripe\b/i, + /\bpayment(?:s)?\b/i, + /\binvoice(?:s)?\b/i, + /\bsubscription(?:s)?\b/i, + /\bcheckout\b/i, + /\bbilling\b/i, + ], +}; + function createEmptyScores(): Record { return CAPABILITY_IDS.reduce( (acc, capability) => { @@ -244,6 +527,23 @@ function createEmptyScores(): Record { ); } +function pushEvidence( + evidence: ClassificationEvidence[], + seen: Set, + entry: ClassificationEvidence, +): void { + const key = JSON.stringify([ + entry.source, + entry.target, + entry.score ?? null, + entry.note ?? null, + ]); + if (!seen.has(key)) { + seen.add(key); + evidence.push(entry); + } +} + export function extractSchemaSignal( schema: ToolInputSchema | undefined, ): string { @@ -258,53 +558,180 @@ export function extractSchemaSignal( function scoreTextSignals( scores: Record, text: string, + evidence?: ClassificationEvidence[], + seen?: Set, + note?: string, ): void { for (const capability of CAPABILITY_IDS) { for (const pattern of CAPABILITY_PATTERNS[capability]) { if (pattern.test(text)) { scores[capability] += 4; + if (evidence && seen) { + pushEvidence(evidence, seen, { + source: "tool_signal", + target: capability, + score: 4, + note: note + ? `${note} matched ${pattern}` + : `Matched ${pattern} in tool signal`, + }); + } } } } } -function getHighestScoringCapability( +function getSortedScoreEntries( scores: Record, -): CapabilityId { - const bestScore = Math.max(...Object.values(scores)); +): Array<{ capability: CapabilityId; score: number }> { + return CAPABILITY_PRIORITY.map((capability) => ({ + capability, + score: scores[capability], + })).sort((a, b) => + b.score === a.score + ? CAPABILITY_PRIORITY.indexOf(a.capability) - + CAPABILITY_PRIORITY.indexOf(b.capability) + : b.score - a.score, + ); +} + +function computeHeuristicConfidence( + bestScore: number, + runnerUpScore: number, +): number { if (bestScore <= 0) { - return "general"; + return 0; } + if (runnerUpScore <= 0) { + return 1; + } + return Number((bestScore / (bestScore + runnerUpScore)).toFixed(4)); +} + +function inferNamespaceFacetsInternal( + namespace: string, + tools: NamespaceToolMetadata[], + facetOverrides: Partial> = {}, +): { + facets: CapabilityFacetId[]; + evidence: ClassificationEvidence[]; +} { + const facets = new Set(); + const evidence: ClassificationEvidence[] = []; + const seen = new Set(); + const namespaceText = namespace.toLowerCase(); + + for (const [facet, patterns] of Object.entries(FACET_PATTERNS)) { + for (const pattern of patterns) { + if (pattern.test(namespaceText)) { + facets.add(facet); + pushEvidence(evidence, seen, { + source: "namespace_hint", + target: facet, + note: `Namespace matched ${pattern}`, + }); + break; + } + } + } + + for (const tool of tools) { + const signal = [ + tool.name, + tool.description ?? "", + extractSchemaSignal(tool.inputSchema), + ] + .join(" ") + .toLowerCase(); - for (const capability of CAPABILITY_PRIORITY) { - if (scores[capability] === bestScore) { - return capability; + for (const [facet, patterns] of Object.entries(FACET_PATTERNS)) { + for (const pattern of patterns) { + if (pattern.test(signal)) { + facets.add(facet); + pushEvidence(evidence, seen, { + source: "tool_signal", + target: facet, + note: `${tool.name} matched ${pattern}`, + }); + break; + } + } } } - return "general"; + for (const facet of facetOverrides[namespace] ?? []) { + facets.add(facet); + pushEvidence(evidence, seen, { + source: "facet_override", + target: facet, + note: "Pinned by config facet override", + }); + } + + return { + facets: [...facets].sort(), + evidence, + }; } -/** - * Infers a namespace capability using deterministic heuristics, with optional - * explicit overrides. - */ -export function inferNamespaceCapability( +/** Returns inferred facets for a namespace. */ +export function inferNamespaceFacets( namespace: string, tools: NamespaceToolMetadata[], - capabilityOverrides: Partial> = {}, -): CapabilityId { + facetOverrides: Partial> = {}, +): CapabilityFacetId[] { + return inferNamespaceFacetsInternal(namespace, tools, facetOverrides).facets; +} + +/** Rich heuristic namespace classification with facets and evidence. */ +export function classifyNamespace( + namespace: string, + tools: NamespaceToolMetadata[], + options: NamespaceClassificationOptions = {}, +): NamespaceClassification { + const capabilityOverrides = options.capabilityOverrides ?? {}; + const capabilityOverrideSources = options.capabilityOverrideSources ?? {}; + const facetOverrides = options.facetOverrides ?? {}; + const { facets, evidence: facetEvidence } = inferNamespaceFacetsInternal( + namespace, + tools, + facetOverrides, + ); + const override = capabilityOverrides[namespace]; if (override) { - return override; + const source = capabilityOverrideSources[namespace] ?? "user_override"; + return { + namespace, + canonicalCapability: override, + capabilitySource: source, + confidence: 1, + facets, + evidence: [ + { + source, + target: override, + note: "Pinned by capability override", + }, + ...facetEvidence, + ], + }; } const scores = createEmptyScores(); + const evidence: ClassificationEvidence[] = []; + const seen = new Set(); const namespaceText = namespace.toLowerCase(); for (const hint of NAMESPACE_HINTS) { if (hint.pattern.test(namespaceText)) { scores[hint.capability] += hint.score; + pushEvidence(evidence, seen, { + source: "namespace_hint", + target: hint.capability, + score: hint.score, + note: `Namespace matched ${hint.pattern}`, + }); } } @@ -316,18 +743,51 @@ export function inferNamespaceCapability( ] .join(" ") .toLowerCase(); - scoreTextSignals(scores, signal); + scoreTextSignals(scores, signal, evidence, seen, tool.name); } - return getHighestScoringCapability(scores); + const ranked = getSortedScoreEntries(scores); + const best = ranked[0] ?? { capability: "general" as CapabilityId, score: 0 }; + const runnerUp = ranked[1]; + const bestCapability = + best.score <= 0 ? ("general" as CapabilityId) : best.capability; + const bestConfidence = computeHeuristicConfidence( + best.score, + runnerUp?.score ?? 0, + ); + + return { + namespace, + canonicalCapability: bestCapability, + capabilitySource: "heuristic", + confidence: bestConfidence, + runnerUp: + runnerUp && runnerUp.score > 0 + ? { + canonicalCapability: runnerUp.capability, + confidence: computeHeuristicConfidence(runnerUp.score, best.score), + } + : undefined, + facets, + evidence: [...evidence, ...facetEvidence], + }; } -/** - * Groups namespace inventories by inferred capability. - */ -export function groupNamespacesByCapability( +/** Rich classification for a batch of inventories. */ +export function classifyNamespaces( inventories: NamespaceInventory[], - capabilityOverrides: Partial> = {}, + options: NamespaceClassificationOptions = {}, +): NamespaceClassification[] { + return [...inventories] + .sort((a, b) => a.namespace.localeCompare(b.namespace)) + .map((inventory) => + classifyNamespace(inventory.namespace, inventory.tools, options), + ); +} + +/** Builds a capability grouping from rich namespace classifications. */ +export function groupClassificationsByCapability( + classifications: NamespaceClassification[], ): CapabilityGrouping { const grouped = CAPABILITY_IDS.reduce( (acc, capability) => { @@ -338,19 +798,40 @@ export function groupNamespacesByCapability( ); const byNamespace: Record = {}; - const sorted = [...inventories].sort((a, b) => + const sorted = [...classifications].sort((a, b) => a.namespace.localeCompare(b.namespace), ); - for (const inventory of sorted) { - const capability = inferNamespaceCapability( - inventory.namespace, - inventory.tools, - capabilityOverrides, - ); - byNamespace[inventory.namespace] = capability; - grouped[capability].push(inventory.namespace); + for (const classification of sorted) { + byNamespace[classification.namespace] = classification.canonicalCapability; + grouped[classification.canonicalCapability].push(classification.namespace); } return { byNamespace, grouped }; } + +/** + * Infers a namespace capability using deterministic heuristics, with optional + * explicit overrides. + */ +export function inferNamespaceCapability( + namespace: string, + tools: NamespaceToolMetadata[], + capabilityOverrides: Partial> = {}, +): CapabilityId { + return classifyNamespace(namespace, tools, { + capabilityOverrides, + }).canonicalCapability; +} + +/** + * Groups namespace inventories by inferred capability. + */ +export function groupNamespacesByCapability( + inventories: NamespaceInventory[], + capabilityOverrides: Partial> = {}, +): CapabilityGrouping { + return groupClassificationsByCapability( + classifyNamespaces(inventories, { capabilityOverrides }), + ); +} diff --git a/src/capabilities/projection.ts b/src/capabilities/projection.ts new file mode 100644 index 0000000..ff2d6c2 --- /dev/null +++ b/src/capabilities/projection.ts @@ -0,0 +1,157 @@ +/** + * Adapter-specific bucket projection for rich namespace classifications. + * + * Keeps MCP²'s canonical capability taxonomy stable while allowing downstream + * integrations to map canonical capabilities + facets into their own bucket + * models. + * + * @module capabilities/projection + */ + +import type { + AdapterProjectionAdapterConfig, + AdapterProjectionConfig, +} from "../config/schema.js"; +import type { NamespaceClassification } from "./inference.js"; + +export type AdapterProjectionSource = + | "canonical" + | "matched_profile" + | "fallback" + | "adapter_override"; + +export interface AdapterProjectionResult { + namespace: string; + adapterId: string; + bucket: string; + confidence: number; + reason: string; + source: AdapterProjectionSource; +} + +function normalizeScore(score: number): number { + if (score <= 0) { + return 0; + } + if (score >= 100) { + return 1; + } + return Number((score / 100).toFixed(4)); +} + +function scoreProfile( + classification: NamespaceClassification, + profile: AdapterProjectionAdapterConfig["capabilities"][number], +): number | null { + if (!profile.acceptsCanonical.includes(classification.canonicalCapability)) { + return null; + } + + let score = 100; + for (const facet of classification.facets) { + if ((profile.prefersFacets ?? []).includes(facet)) { + score += 15; + } + if ((profile.rejectsFacets ?? []).includes(facet)) { + score -= 35; + } + } + return score; +} + +export function projectNamespaceClassification( + adapterId: string, + classification: NamespaceClassification, + adapterConfig?: AdapterProjectionAdapterConfig, +): AdapterProjectionResult { + if (!adapterConfig || adapterConfig.mode === "canonical") { + return { + namespace: classification.namespace, + adapterId, + bucket: classification.canonicalCapability, + confidence: 1, + reason: "canonical capability", + source: "canonical", + }; + } + + const overrideBucket = + adapterConfig.namespaceBucketOverrides?.[classification.namespace]; + if (overrideBucket) { + return { + namespace: classification.namespace, + adapterId, + bucket: overrideBucket, + confidence: 1, + reason: "namespace override", + source: "adapter_override", + }; + } + + const ranked = (adapterConfig.capabilities ?? []) + .map((profile) => ({ + profile, + score: scoreProfile(classification, profile), + })) + .filter( + ( + entry, + ): entry is typeof entry & { + score: number; + } => entry.score !== null && entry.score > 0, + ) + .sort((a, b) => b.score - a.score); + + const best = ranked[0]; + if (best) { + return { + namespace: classification.namespace, + adapterId, + bucket: best.profile.id, + confidence: normalizeScore(best.score), + reason: `matched adapter profile ${best.profile.id}`, + source: "matched_profile", + }; + } + + if (adapterConfig.fallbackBucket) { + return { + namespace: classification.namespace, + adapterId, + bucket: adapterConfig.fallbackBucket, + confidence: 0.5, + reason: "adapter fallback bucket", + source: "fallback", + }; + } + + return { + namespace: classification.namespace, + adapterId, + bucket: classification.canonicalCapability, + confidence: 1, + reason: "canonical capability", + source: "canonical", + }; +} + +export function projectNamespaceClassifications( + adapterId: string, + classifications: NamespaceClassification[], + projectionConfig?: AdapterProjectionConfig, +): AdapterProjectionResult[] { + const adapterConfig = + projectionConfig?.adapters[adapterId] ?? + (adapterId === "mcp2" + ? { + mode: "canonical", + fallbackBucket: undefined, + capabilities: [], + namespaceBucketOverrides: {}, + } + : undefined); + + return classifications.map((classification) => + projectNamespaceClassification(adapterId, classification, adapterConfig), + ); +} diff --git a/src/capabilities/semantic-classifier.ts b/src/capabilities/semantic-classifier.ts index 961c8cb..d48ad45 100644 --- a/src/capabilities/semantic-classifier.ts +++ b/src/capabilities/semantic-classifier.ts @@ -33,10 +33,20 @@ const CAPABILITY_REFERENCE_TEXTS: Record = { "Automate web browser interactions: click elements, fill forms, take screenshots, inspect DOM nodes, navigate URLs, execute JavaScript in page context, and run browser diagnostics.", issue_tracking: "Manage project management tickets, kanban boards, sprints, and work items. Create, update, and track issues in project trackers like Jira, Linear, Asana, and ClickUp.", + observability: + "Monitor systems, track incidents, inspect logs, errors, exceptions, traces, and metrics. Work with observability and error-tracking tools like Sentry, Datadog, Grafana, Rollbar, and New Relic.", + messaging: + "Send and manage chat messages, channels, threads, notifications, email, direct messages, and team communication workflows. Work with messaging tools like Slack, Discord, Microsoft Teams, Telegram, and Twilio.", + payments: + "Manage payments, subscriptions, invoices, checkout sessions, billing, charges, refunds, and customer payment workflows. Work with payment platforms like Stripe and related billing APIs.", + database: + "Manage databases, SQL queries, table schemas, migrations, rows, columns, and data operations. Work with database platforms and ORMs like Postgres, MySQL, SQLite, Prisma, and Supabase.", cms_content: "Manage wiki pages, knowledge base articles, content documents, blog posts, editorial workflows, and structured content. Create and organize content in systems like Notion, Confluence, and Sanity.", design: - "Create and inspect visual design artifacts, UI mockups, wireframes, and design system components. Work with design tools like Figma, Sketch, and Pencil for visual layout and styling.", + "Create and inspect visual design artifacts, UI mockups, wireframes, screenshots, diagrams, and visual layouts. Work with visual design tools for styling and mockup review.", + design_workspace: + "Edit structured design workspace files, canvases, layout state, selections, variables, components, and design-to-code assets. Work with Pencil .pen files and Figma or FigJam workspaces, design context, code connect mappings, workspace hierarchy, layout snapshots, variables, and code synchronization flows.", ai_media_generation: "Generate and edit images, videos, and visual media using AI models. Create images from text prompts, edit existing images with AI, upscale resolution, apply style transfer, inpaint or outpaint regions, and generate sequential or consistent media. Supports text-to-image, image-to-image, and AI-powered visual content creation.", hosting_deploy: @@ -46,9 +56,35 @@ const CAPABILITY_REFERENCE_TEXTS: Record = { research: "Search the web, collect information from multiple sources, synthesize findings, and perform web research and data gathering operations.", general: - "General-purpose utility operations, API integrations, data transformations, notifications, messaging, payments, and miscellaneous tool actions.", + "General-purpose utility operations, API integrations, data transformations, and miscellaneous tool actions.", }; +/** + * Narrow deterministic priors for capability families whose namespaces/tool + * vocabularies are highly distinctive in practice. These are intentionally + * small and only nudge the embedding score so hybrid mode stays aligned with + * the canonical taxonomy for obvious cases such as Figma/Pencil workspaces and + * AI media generators like Wavespeed. + */ +const SEMANTIC_PRIOR_PATTERNS: Partial> = { + ai_media_generation: [ + /(wavespeed|stability|replicate|midjourney|dall.?e|runway|imagen|flux|fal\.ai|dreamstudio)/i, + /\btext.to.image\b/i, + /\bimage.to.image\b/i, + /\bprompt\b.*\bimage\b/i, + ], + design_workspace: [ + /(pencil|figma|figjam)/i, + /\b\.pen\b/i, + /\bbatch_design\b/i, + /\bget_design_context\b/i, + /\bget_variable_defs\b/i, + /\bcode connect\b/i, + ], +}; + +const SEMANTIC_PRIOR_BOOST = 0.12; + /** Result of classifying a single namespace. */ export interface SemanticClassificationResult { /** Best-matching capability */ @@ -139,14 +175,14 @@ export class SemanticCapabilityClassifier { const startTime = performance.now(); const signalText = this.buildSignalText(namespace, tools); const signalResult = await this.generator.embed(signalText, true); + const priorBoosts = this.computePriorBoosts(signalText); // Compute cosine similarity against each capability reference const scores: Array<{ capability: CapabilityId; similarity: number }> = []; for (const [capId, refEmb] of this.referenceEmbeddings) { - const similarity = EmbeddingGenerator.cosineSimilarity( - signalResult.embedding, - refEmb, - ); + const similarity = + EmbeddingGenerator.cosineSimilarity(signalResult.embedding, refEmb) + + (priorBoosts[capId] ?? 0); scores.push({ capability: capId, similarity }); } @@ -219,4 +255,21 @@ export class SemanticCapabilityClassifier { } return parts.join(" "); } + + private computePriorBoosts( + signalText: string, + ): Partial> { + const boosts: Partial> = {}; + for (const [capability, patterns] of Object.entries( + SEMANTIC_PRIOR_PATTERNS, + ) as Array<[CapabilityId, RegExp[] | undefined]>) { + if (!patterns) { + continue; + } + if (patterns.some((pattern) => pattern.test(signalText))) { + boosts[capability] = SEMANTIC_PRIOR_BOOST; + } + } + return boosts; + } } diff --git a/src/config/index.ts b/src/config/index.ts index 5332216..0b179f1 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -58,6 +58,13 @@ export { saveConfigSync, } from "./save.js"; export { + type AdapterCapabilityProfile, + AdapterCapabilityProfileSchema, + type AdapterProjectionAdapterConfig, + AdapterProjectionAdapterSchema, + type AdapterProjectionConfig, + AdapterProjectionModeSchema, + AdapterProjectionSchema, ConfigSchema, DEFAULT_CONFIG, DEFAULT_RESPONSE_RESOURCE_CONFIG, diff --git a/src/config/schema.ts b/src/config/schema.ts index 19456c4..9f69894 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -175,6 +175,9 @@ export const DynamicToolSurfaceSchema = z.object({ capabilityOverrides: z .record(z.string().min(1), CapabilityIdSchema) .default({}), + facetOverrides: z + .record(z.string().min(1), z.array(z.string().min(1))) + .default({}), /** Minimum cosine similarity for ML classification to override heuristic (hybrid mode only) */ semanticConfidenceThreshold: z.number().min(0).max(1).default(0.45), }); @@ -182,6 +185,44 @@ export const DynamicToolSurfaceSchema = z.object({ /** Dynamic tool surface configuration type. */ export type DynamicToolSurfaceConfig = z.infer; +export const AdapterProjectionModeSchema = z.enum(["canonical", "projected"]); + +export const AdapterCapabilityProfileSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + summary: z.string().min(1), + acceptsCanonical: z.array(CapabilityIdSchema).default([]), + prefersFacets: z.array(z.string().min(1)).default([]), + rejectsFacets: z.array(z.string().min(1)).default([]), +}); + +export type AdapterCapabilityProfile = z.infer< + typeof AdapterCapabilityProfileSchema +>; + +export const AdapterProjectionAdapterSchema = z.object({ + mode: AdapterProjectionModeSchema.default("canonical"), + fallbackBucket: z.string().min(1).optional(), + capabilities: z.array(AdapterCapabilityProfileSchema).default([]), + namespaceBucketOverrides: z + .record(z.string().min(1), z.string().min(1)) + .default({}), +}); + +export type AdapterProjectionAdapterConfig = z.infer< + typeof AdapterProjectionAdapterSchema +>; + +export const AdapterProjectionSchema = z.object({ + enabled: z.boolean().default(false), + defaultAdapter: z.string().min(1).default("mcp2"), + adapters: z + .record(z.string().min(1), AdapterProjectionAdapterSchema) + .default({}), +}); + +export type AdapterProjectionConfig = z.infer; + /** Schema for internal retrieval defaults */ const PreferredNamespacesByIntentSchema = z.object({ /** Namespaces to prioritize for codebase search/retrieval intents */ @@ -294,8 +335,14 @@ export const OperationsSchema = z inference: "heuristic_with_overrides", refresh: "on_connect", capabilityOverrides: {}, + facetOverrides: {}, semanticConfidenceThreshold: 0.45, }), + adapterProjection: AdapterProjectionSchema.default({ + enabled: false, + defaultAdapter: "mcp2", + adapters: {}, + }), }) .default({ findTools: { @@ -318,8 +365,14 @@ export const OperationsSchema = z inference: "heuristic_with_overrides", refresh: "on_connect", capabilityOverrides: {}, + facetOverrides: {}, semanticConfidenceThreshold: 0.45, }, + adapterProjection: { + enabled: false, + defaultAdapter: "mcp2", + adapters: {}, + }, }); /** diff --git a/src/security/policy.ts b/src/security/policy.ts index 67a88cb..d4a5ab5 100644 --- a/src/security/policy.ts +++ b/src/security/policy.ts @@ -34,6 +34,16 @@ interface PendingConfirmation { const CONFIRMATION_TTL_MS = 5 * 60 * 1000; // 5 minutes +/** + * Transitional policy aliases for canonical capability renames/splits. + * + * These aliases are one-way: old broader policies continue to match newer, + * more specific canonical capabilities, but not vice versa. + */ +const POLICY_SCOPE_ALIASES: Record = { + design_workspace: ["design"], +}; + // In-memory store for pending confirmations const pendingConfirmations = new Map(); @@ -57,7 +67,9 @@ export function matchesPattern( return false; } - const serverMatches = patternServer === "*" || patternServer === serverKey; + const policyScopes = [serverKey, ...(POLICY_SCOPE_ALIASES[serverKey] ?? [])]; + const serverMatches = + patternServer === "*" || policyScopes.includes(patternServer); const toolMatches = patternTool === "*" || patternTool === toolName; return serverMatches && toolMatches; diff --git a/src/status/runner.ts b/src/status/runner.ts index e30df6e..c1e08a9 100644 --- a/src/status/runner.ts +++ b/src/status/runner.ts @@ -7,7 +7,15 @@ * @module status/runner */ -import { groupNamespacesByCapability } from "../capabilities/inference.js"; +import { + classifyNamespaces, + groupClassificationsByCapability, + type NamespaceClassification, +} from "../capabilities/inference.js"; +import { + type AdapterProjectionResult, + projectNamespaceClassifications, +} from "../capabilities/projection.js"; import { buildCapabilityRouters, type CapabilityRouter, @@ -46,6 +54,11 @@ export interface UpstreamStatus { export interface StatusResult { upstreams: UpstreamStatus[]; routers: CapabilityRouter[]; + classifications?: NamespaceClassification[]; + adapterProjection?: { + adapterId: string; + projections: AdapterProjectionResult[]; + }; configPath?: string; contextStats?: ContextStats; } @@ -110,18 +123,53 @@ export async function collectStatus( .sort((a, b) => a.namespace.localeCompare(b.namespace)); let routers: CapabilityRouter[] = []; + let classifications: NamespaceClassification[] = []; + let adapterProjection: + | { + adapterId: string; + projections: AdapterProjectionResult[]; + } + | undefined; if (inventories.length > 0) { const overrides = config.operations.dynamicToolSurface.capabilityOverrides ?? {}; - const grouping = groupNamespacesByCapability(inventories, overrides); + const overrideSources = Object.fromEntries( + Object.keys(overrides).map((namespace) => [namespace, "user_override"]), + ) as Record; + const facetOverrides = + config.operations.dynamicToolSurface.facetOverrides ?? {}; + classifications = classifyNamespaces(inventories, { + capabilityOverrides: overrides, + capabilityOverrideSources: overrideSources, + facetOverrides, + }); + const grouping = groupClassificationsByCapability(classifications); routers = buildCapabilityRouters(inventories, grouping); + + if (config.operations.adapterProjection.enabled) { + const adapterId = config.operations.adapterProjection.defaultAdapter; + adapterProjection = { + adapterId, + projections: projectNamespaceClassifications( + adapterId, + classifications, + config.operations.adapterProjection, + ), + }; + } } // Compute context savings stats const allUpstreamTools = inventories.flatMap((inv) => inv.tools); const contextStats = computeContextStats(allUpstreamTools, routers); - return { upstreams, routers, contextStats }; + return { + upstreams, + routers, + classifications, + ...(adapterProjection ? { adapterProjection } : {}), + contextStats, + }; } finally { await cataloger.disconnectAll(); } @@ -180,7 +228,51 @@ export function formatStatus( } } - // Section 2: Capability Routing + // Section 2: Namespace Classification (verbose only) + if ( + options.verbose && + result.classifications && + result.classifications.length + ) { + lines.push(""); + lines.push( + `${DIM}── Namespace Classification ─────────────────────────${RESET}`, + ); + + const projectionsByNamespace = new Map(); + for (const projection of result.adapterProjection?.projections ?? []) { + projectionsByNamespace.set(projection.namespace, projection); + } + + for (const classification of result.classifications) { + lines.push( + ` ${classification.namespace.padEnd(24)} ${BOLD}${classification.canonicalCapability}${RESET}`, + ); + lines.push(` ${DIM}source=${classification.capabilitySource}${RESET}`); + lines.push( + ` ${DIM}confidence=${classification.confidence.toFixed(2)}${RESET}`, + ); + if (classification.runnerUp) { + lines.push( + ` ${DIM}runner-up=${classification.runnerUp.canonicalCapability} (${classification.runnerUp.confidence.toFixed(2)})${RESET}`, + ); + } + if (classification.facets.length > 0) { + lines.push( + ` ${DIM}facets:${RESET} ${classification.facets.join(", ")}`, + ); + } + + const projection = projectionsByNamespace.get(classification.namespace); + if (projection && result.adapterProjection) { + lines.push( + ` ${DIM}projection[${result.adapterProjection.adapterId}]:${RESET} ${projection.bucket} ${DIM}(${projection.source})${RESET}`, + ); + } + } + } + + // Section 3: Capability Routing lines.push(""); lines.push( `${DIM}── Capability Routing ────────────────────────────────${RESET}`, @@ -226,7 +318,7 @@ export function formatStatus( lines.push(""); // blank line between capabilities } - // Section 3: Context Savings (verbose only) + // Section 4: Context Savings (verbose only) if (options.verbose && result.contextStats) { const cs = result.contextStats; if (cs.upstreamToolCount > 0) { diff --git a/src/utils/capability-meta.ts b/src/utils/capability-meta.ts index a58bde6..2a864bb 100644 --- a/src/utils/capability-meta.ts +++ b/src/utils/capability-meta.ts @@ -34,10 +34,20 @@ export function capabilitySummary(capability: string): string { return "Automate browser interactions and diagnostics."; case "issue_tracking": return "Work with issues, tickets, and project tracking."; + case "observability": + return "Work with monitoring, incidents, logs, and error tracking."; + case "messaging": + return "Work with chat, messages, channels, and notifications."; + case "payments": + return "Work with payments, subscriptions, invoices, and billing."; + case "database": + return "Work with databases, SQL, schemas, and data operations."; case "cms_content": return "Manage content and CMS resources."; case "design": return "Create and inspect design artifacts and visuals."; + case "design_workspace": + return "Work with structured design workspaces, layout state, tokens, and design-to-code flows."; case "ai_media_generation": return "Generate and edit images and media using AI models."; case "hosting_deploy": diff --git a/tests/capability-classification.test.ts b/tests/capability-classification.test.ts new file mode 100644 index 0000000..6b52c84 --- /dev/null +++ b/tests/capability-classification.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, test } from "bun:test"; +import { + CAPABILITY_IDS, + type CapabilityId, + classifyNamespace, + type NamespaceToolMetadata, +} from "@/capabilities/inference"; +import { projectNamespaceClassification } from "@/capabilities/projection"; +import { SemanticCapabilityClassifier } from "@/capabilities/semantic-classifier"; +import type { EmbeddingGenerator } from "@/embeddings/generator"; + +const PENCIL_TOOLS: NamespaceToolMetadata[] = [ + { + name: "batch_design", + description: + "Create, modify, and manipulate design elements in a .pen canvas", + }, + { + name: "snapshot_layout", + description: "Analyze layout structure and detect overlapping elements", + }, + { + name: "get_variables", + description: "Read design tokens and sync theme values with CSS variables", + }, + { + name: "export_react", + description: "Generate React code for this design system component", + }, +]; + +describe("namespace classification", () => { + test("returns canonical capability, facets, and evidence for Pencil-like tools", () => { + const classification = classifyNamespace("pencil", PENCIL_TOOLS); + + expect(classification.namespace).toBe("pencil"); + expect(classification.canonicalCapability).toBe("design_workspace"); + expect(classification.capabilitySource).toBe("heuristic"); + expect(classification.confidence).toBeGreaterThan(0); + expect(classification.runnerUp).toBeDefined(); + expect(classification.facets).toEqual( + expect.arrayContaining([ + "design_workspace", + "layout_analysis", + "design_tokens", + "design_to_code", + ]), + ); + expect(classification.evidence).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: "namespace_hint", + target: "design_workspace", + }), + expect.objectContaining({ + source: "tool_signal", + target: "design_workspace", + }), + ]), + ); + }); + + test("respects capability and facet overrides with explicit source metadata", () => { + const classification = classifyNamespace("pencil", PENCIL_TOOLS, { + capabilityOverrides: { pencil: "general" }, + capabilityOverrideSources: { pencil: "user_override" }, + facetOverrides: { pencil: ["custom_workspace"] }, + }); + + expect(classification.canonicalCapability).toBe("general"); + expect(classification.capabilitySource).toBe("user_override"); + expect(classification.facets).toEqual( + expect.arrayContaining(["design_workspace", "custom_workspace"]), + ); + expect(classification.evidence).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: "user_override", + target: "general", + }), + ]), + ); + }); +}); + +describe("adapter projection", () => { + test("prefers namespace bucket overrides over profile scoring", () => { + const classification = classifyNamespace("pencil", PENCIL_TOOLS); + + const projection = projectNamespaceClassification( + "gateway", + classification, + { + mode: "projected", + fallbackBucket: "general", + capabilities: [ + { + id: "design", + title: "Design Analysis", + summary: "Analyze screenshots, diagrams, and visual diffs.", + acceptsCanonical: ["design_workspace"], + prefersFacets: [], + rejectsFacets: [], + }, + ], + namespaceBucketOverrides: { pencil: "general" }, + }, + ); + + expect(projection.bucket).toBe("general"); + expect(projection.source).toBe("adapter_override"); + expect(projection.reason).toBe("namespace override"); + }); + + test("can project a canonical design_workspace classification away from screenshot-analysis design buckets", () => { + const classification = classifyNamespace("pencil", PENCIL_TOOLS); + + const projection = projectNamespaceClassification( + "gateway", + classification, + { + mode: "projected", + fallbackBucket: "general", + capabilities: [ + { + id: "design", + title: "Design Analysis", + summary: "Analyze screenshots, diagrams, and visual diffs.", + acceptsCanonical: ["design", "browser_automation", "research"], + prefersFacets: [ + "vision_analysis", + "ocr", + "diagram_understanding", + "ui_diff", + ], + rejectsFacets: [ + "design_workspace", + "design_tokens", + "design_to_code", + ], + }, + { + id: "general", + title: "General", + summary: "Fallback bucket for tools that do not map cleanly.", + acceptsCanonical: [ + "general", + "design", + "design_workspace", + "docs", + "research", + ], + prefersFacets: [], + rejectsFacets: [], + }, + ], + namespaceBucketOverrides: {}, + }, + ); + + expect(projection.bucket).toBe("general"); + expect(projection.adapterId).toBe("gateway"); + expect(projection.reason).toContain("matched adapter profile"); + }); + + test("falls back when all matching profiles are rejected to non-positive scores", () => { + const classification = classifyNamespace("pencil", PENCIL_TOOLS); + + const projection = projectNamespaceClassification( + "gateway", + classification, + { + mode: "projected", + fallbackBucket: "general", + capabilities: [ + { + id: "design", + title: "Design Analysis", + summary: "Analyze screenshots, diagrams, and visual diffs.", + acceptsCanonical: ["design_workspace"], + prefersFacets: [], + rejectsFacets: [ + "design_workspace", + "design_tokens", + "design_to_code", + ], + }, + ], + namespaceBucketOverrides: {}, + }, + ); + + expect(projection.bucket).toBe("general"); + expect(projection.source).toBe("fallback"); + }); +}); + +describe("semantic classifier", () => { + function normalizedVector(values: number[]): Float32Array { + const vec = new Float32Array(values); + let norm = 0; + for (const value of vec) { + norm += value * value; + } + const mag = Math.sqrt(norm); + for (let i = 0; i < vec.length; i++) { + const value = vec[i] ?? 0; + vec[i] = value / mag; + } + return vec; + } + + test("classifies Pencil-like tools as design_workspace in semantic mode", async () => { + const designWorkspaceVector = normalizedVector([1, 0, 0, 0]); + const designVector = normalizedVector([0, 1, 0, 0]); + const otherVector = normalizedVector([0, 0, 1, 0]); + const capabilityVectors = {} as Record; + for (const capability of CAPABILITY_IDS) { + capabilityVectors[capability] = otherVector; + } + capabilityVectors.design = designVector; + capabilityVectors.design_workspace = designWorkspaceVector; + + const fakeGenerator = { + async embedBatch(texts: string[]) { + expect(texts).toHaveLength(CAPABILITY_IDS.length); + return { + embeddings: CAPABILITY_IDS.map( + (capability) => capabilityVectors[capability], + ), + dimensions: 4, + inferenceMs: 0, + avgPerEmbeddingMs: 0, + }; + }, + async embed(text: string) { + return { + embedding: + text.includes(".pen") || text.includes("batch_design") + ? designWorkspaceVector + : otherVector, + dimensions: 4, + inferenceMs: 0, + }; + }, + } as unknown as EmbeddingGenerator; + + const classifier = new SemanticCapabilityClassifier(fakeGenerator, { + confidenceThreshold: 0, + }); + await classifier.initializeReferences(); + + const result = await classifier.classify("pencil", PENCIL_TOOLS); + + expect(result.capability).toBe("design_workspace"); + }); +}); diff --git a/tests/capability-inference.test.ts b/tests/capability-inference.test.ts index 47525d2..3c88562 100644 --- a/tests/capability-inference.test.ts +++ b/tests/capability-inference.test.ts @@ -79,6 +79,18 @@ describe("capability inference", () => { * correct capability instead. */ describe("heuristic misclassification regression cases", () => { + test("short tokens in namespace hints do not match unrelated namespaces", () => { + const minimalTools = [{ name: "create", description: "Create output" }]; + + expect(inferNamespaceCapability("catalog", minimalTools)).not.toBe( + "observability", + ); + expect(inferNamespaceCapability("admin", minimalTools)).not.toBe( + "messaging", + ); + expect(inferNamespaceCapability("build", minimalTools)).not.toBe("design"); + }); + test("Notion: misclassified as browser_automation (should be cms_content)", () => { // "page" means wiki page, not browser page — semantic collision const capability = inferNamespaceCapability("notion", [ @@ -95,18 +107,56 @@ describe("heuristic misclassification regression cases", () => { expect(capability).toBe("browser_automation"); }); - test("Sentry: misclassified as issue_tracking (should be general)", () => { - // "issue" means error/exception, not project management ticket + test("Sentry: classified as observability", () => { + // "issue" here refers to errors/incidents and should now land in the + // canonical observability bucket rather than project tracking. const capability = inferNamespaceCapability("sentry", [ { name: "list_issues", description: "List error issues in a project" }, { name: "get_issue", description: "Get details of a specific issue" }, { name: "resolve_issue", description: "Resolve an issue" }, ]); - expect(capability).toBe("issue_tracking"); + expect(capability).toBe("observability"); + }); + + test("Slack: classified as messaging", () => { + const capability = inferNamespaceCapability("slack", [ + { + name: "send_message", + description: "Send a message to a Slack channel", + }, + { + name: "list_channels", + description: "List available Slack channels", + }, + { + name: "get_thread", + description: "Get a message thread from a channel", + }, + ]); + expect(capability).toBe("messaging"); + }); + + test("Stripe: classified as payments", () => { + const capability = inferNamespaceCapability("stripe", [ + { + name: "create_checkout_session", + description: "Create a checkout session for a subscription payment", + }, + { + name: "list_invoices", + description: "List invoices for a customer account", + }, + { + name: "get_subscription", + description: "Get subscription billing details", + }, + ]); + expect(capability).toBe("payments"); }); - test("Prisma: misclassified as cms_content (should be general)", () => { - // "schema" and "migration" are database concepts, not CMS concepts + test("Prisma: classified as database", () => { + // "schema" and "migration" are database concepts and should now land in + // the canonical database bucket. const capability = inferNamespaceCapability("prisma", [ { name: "introspect_schema", @@ -118,7 +168,7 @@ describe("heuristic misclassification regression cases", () => { }, { name: "apply_migration", description: "Apply pending migrations" }, ]); - expect(capability).toBe("cms_content"); + expect(capability).toBe("database"); }); test("shadcn: correctly classified as docs (fixed — was design)", () => { @@ -133,8 +183,8 @@ describe("heuristic misclassification regression cases", () => { expect(capability).toBe("docs"); }); - test("Supabase: misclassified as issue_tracking (taxonomy gap — no database category)", () => { - // Database-as-a-service doesn't map to any of the 10 categories + test("Supabase: classified as database", () => { + // Database-as-a-service now has a canonical bucket. const capability = inferNamespaceCapability("supabase", [ { name: "list_projects", description: "List all Supabase projects" }, { name: "run_query", description: "Execute a SQL query" }, @@ -150,7 +200,7 @@ describe("heuristic misclassification regression cases", () => { }, }, ]); - expect(capability).toBe("issue_tracking"); + expect(capability).toBe("database"); }); test("wavespeed-cli-mcp: correctly classified as ai_media_generation (fixed — was design)", () => { diff --git a/tests/capability-meta.test.ts b/tests/capability-meta.test.ts index 45e8899..822e488 100644 --- a/tests/capability-meta.test.ts +++ b/tests/capability-meta.test.ts @@ -11,6 +11,7 @@ describe("capabilityTitle", () => { expect(capabilityTitle("ai_media_generation")).toBe("Ai Media Generation"); expect(capabilityTitle("hosting_deploy")).toBe("Hosting Deploy"); expect(capabilityTitle("time_util")).toBe("Time Util"); + expect(capabilityTitle("design_workspace")).toBe("Design Workspace"); }); test("handles single-word IDs", () => { @@ -43,8 +44,14 @@ describe("capabilitySummary", () => { docs: "Query and read technical documentation.", browser_automation: "Automate browser interactions and diagnostics.", issue_tracking: "Work with issues, tickets, and project tracking.", + observability: "Work with monitoring, incidents, logs, and error tracking.", + messaging: "Work with chat, messages, channels, and notifications.", + database: "Work with databases, SQL, schemas, and data operations.", + payments: "Work with payments, subscriptions, invoices, and billing.", cms_content: "Manage content and CMS resources.", design: "Create and inspect design artifacts and visuals.", + design_workspace: + "Work with structured design workspaces, layout state, tokens, and design-to-code flows.", ai_media_generation: "Generate and edit images and media using AI models.", hosting_deploy: "Manage deployments, hosting, and infrastructure operations.", diff --git a/tests/config.test.ts b/tests/config.test.ts index d6ff7ee..df07d26 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -83,6 +83,72 @@ describe("ConfigSchema", () => { }); }); + test("parses facet overrides for dynamic tool surface classification", () => { + const result = ConfigSchema.parse({ + operations: { + dynamicToolSurface: { + facetOverrides: { + pencil: ["design_workspace", "design_tokens"], + }, + }, + }, + }); + + expect(result.operations.dynamicToolSurface.facetOverrides).toEqual({ + pencil: ["design_workspace", "design_tokens"], + }); + }); + + test("parses adapter projection configuration", () => { + const result = ConfigSchema.parse({ + operations: { + adapterProjection: { + enabled: true, + defaultAdapter: "gateway", + adapters: { + gateway: { + mode: "projected", + fallbackBucket: "general", + capabilities: [ + { + id: "design", + title: "Design Analysis", + summary: "Analyze screenshots", + acceptsCanonical: ["design"], + prefersFacets: ["vision_analysis"], + rejectsFacets: ["design_workspace"], + }, + ], + namespaceBucketOverrides: { + pencil: "general", + }, + }, + }, + }, + }, + }); + + expect(result.operations.adapterProjection.enabled).toBe(true); + expect(result.operations.adapterProjection.defaultAdapter).toBe("gateway"); + expect(result.operations.adapterProjection.adapters["gateway"]).toEqual({ + mode: "projected", + fallbackBucket: "general", + capabilities: [ + { + id: "design", + title: "Design Analysis", + summary: "Analyze screenshots", + acceptsCanonical: ["design"], + prefersFacets: ["vision_analysis"], + rejectsFacets: ["design_workspace"], + }, + ], + namespaceBucketOverrides: { + pencil: "general", + }, + }); + }); + test("accepts 'hybrid' inference mode", () => { const result = ConfigSchema.parse({ operations: { @@ -233,6 +299,14 @@ describe("DEFAULT_CONFIG", () => { expect( DEFAULT_CONFIG.operations.dynamicToolSurface.capabilityOverrides, ).toEqual({}); + expect(DEFAULT_CONFIG.operations.dynamicToolSurface.facetOverrides).toEqual( + {}, + ); + expect(DEFAULT_CONFIG.operations.adapterProjection.enabled).toBe(false); + expect(DEFAULT_CONFIG.operations.adapterProjection.defaultAdapter).toBe( + "mcp2", + ); + expect(DEFAULT_CONFIG.operations.adapterProjection.adapters).toEqual({}); expect(DEFAULT_CONFIG.operations.responseResource).toEqual( DEFAULT_RESPONSE_RESOURCE_CONFIG, ); diff --git a/tests/figma-classification.test.ts b/tests/figma-classification.test.ts new file mode 100644 index 0000000..0ed0990 --- /dev/null +++ b/tests/figma-classification.test.ts @@ -0,0 +1,53 @@ +/** + * Component test: official Figma MCP server classification. + * + * Uses a captured fixture from Figma's official MCP tools documentation: + * https://developers.figma.com/docs/figma-mcp-server/tools-and-prompts/ + * + * The Figma MCP server is workspace/editor oriented: design context, variables, + * Code Connect mappings, metadata, and FigJam operations. It should land in the + * canonical `design_workspace` bucket, not generic visual `design`. + */ +import { describe, expect, test } from "bun:test"; +import { + classifyNamespace, + groupNamespacesByCapability, + inferNamespaceCapability, + type NamespaceToolMetadata, +} from "@/capabilities/inference"; +import figmaFixture from "./fixtures/figma-mcp-tools.json"; + +const FIGMA_TOOLS = figmaFixture.tools as NamespaceToolMetadata[]; + +describe("figma classification", () => { + test("official Figma MCP fixture is classified as design_workspace", () => { + const capability = inferNamespaceCapability("figma", FIGMA_TOOLS); + expect(capability).toBe("design_workspace"); + }); + + test("rich classification keeps Figma in design_workspace with workspace facets", () => { + const classification = classifyNamespace("figma", FIGMA_TOOLS); + + expect(classification.canonicalCapability).toBe("design_workspace"); + expect(classification.capabilitySource).toBe("heuristic"); + expect(classification.facets).toEqual( + expect.arrayContaining([ + "design_workspace", + "design_tokens", + "design_to_code", + "layout_analysis", + ]), + ); + }); + + test("grouping places Figma under the design_workspace router", () => { + const grouping = groupNamespacesByCapability( + [{ namespace: "figma", tools: FIGMA_TOOLS }], + {}, + ); + + expect(grouping.byNamespace["figma"]).toBe("design_workspace"); + expect(grouping.grouped.design_workspace).toContain("figma"); + expect(grouping.grouped.design).not.toContain("figma"); + }); +}); diff --git a/tests/fixtures/figma-mcp-tools.json b/tests/fixtures/figma-mcp-tools.json new file mode 100644 index 0000000..8189a19 --- /dev/null +++ b/tests/fixtures/figma-mcp-tools.json @@ -0,0 +1,55 @@ +{ + "capturedFrom": "https://developers.figma.com/docs/figma-mcp-server/tools-and-prompts/", + "capturedAt": "2026-03-08", + "server": "figma", + "tools": [ + { + "name": "get_design_context", + "description": "Get the design context for a layer or selection" + }, + { + "name": "get_variable_defs", + "description": "Returns the variables and styles used in your Figma selection" + }, + { + "name": "get_code_connect_map", + "description": "Retrieves a mapping between Figma node IDs and their corresponding code components in your codebase" + }, + { + "name": "add_code_connect_map", + "description": "Adds a mapping between a Figma node ID and its corresponding code component in your codebase" + }, + { + "name": "get_screenshot", + "description": "Allows the agent to take a screenshot of your selection" + }, + { + "name": "create_design_system_rules", + "description": "Creates a rule file that provide agents with the right context to translate designs into frontend code" + }, + { + "name": "get_metadata", + "description": "Returns a sparse XML representation of your selection that contains basic properties such as layer IDs, names, types, position and sizes" + }, + { + "name": "get_figjam", + "description": "Converts FigJam diagrams to XML" + }, + { + "name": "generate_diagram", + "description": "Generates a FigJam diagram from Mermaid syntax" + }, + { + "name": "whoami", + "description": "Returns the identity of the user that's authenticated to Figma" + }, + { + "name": "get_code_connect_suggestions", + "description": "Find suggestions for mapping Figma node IDs to corresponding code components in your codebase using Code Connect" + }, + { + "name": "send_code_connect_mappings", + "description": "Confirm suggested Code Connect mappings" + } + ] +} diff --git a/tests/security-policy.test.ts b/tests/security-policy.test.ts index 9104c44..e7ede74 100644 --- a/tests/security-policy.test.ts +++ b/tests/security-policy.test.ts @@ -68,6 +68,15 @@ describe("matchesPattern", () => { test("does not match different server with server:*", () => { expect(matchesPattern("fs:*", "db", "read_file")).toBe(false); }); + + test("legacy design policies match design_workspace during transition", () => { + expect( + matchesPattern("design:*", "design_workspace", "batch_design"), + ).toBe(true); + expect( + matchesPattern("design_workspace:*", "design", "inspect_artifact"), + ).toBe(false); + }); }); describe("full wildcard", () => { @@ -123,6 +132,15 @@ describe("evaluatePolicy", () => { ); expect(result.decision).toBe("allow"); }); + + test("legacy design allow policies still allow design_workspace actions", () => { + const config = createConfig({ allow: ["design:*"] }); + const result = evaluatePolicy( + { capability: "design_workspace", action: "batch_design" }, + config, + ); + expect(result.decision).toBe("allow"); + }); }); describe("block list", () => { diff --git a/tests/status-runner.test.ts b/tests/status-runner.test.ts index 3f758fb..c7047e3 100644 --- a/tests/status-runner.test.ts +++ b/tests/status-runner.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test"; +import type { NamespaceClassification } from "@/capabilities/inference"; +import type { AdapterProjectionResult } from "@/capabilities/projection"; import type { CapabilityRouter } from "@/capabilities/routing"; import type { StatusResult, UpstreamStatus } from "@/status/runner"; import { formatStatus } from "@/status/runner"; @@ -494,4 +496,60 @@ describe("formatStatus", () => { expect(output).toContain("Without MCP"); expect(output).not.toContain("Saved:"); }); + + test("verbose mode shows namespace classification details and adapter projection", () => { + const classifications: NamespaceClassification[] = [ + { + namespace: "pencil", + canonicalCapability: "design_workspace", + capabilitySource: "user_override", + confidence: 1, + runnerUp: { + canonicalCapability: "docs", + confidence: 0.2, + }, + facets: ["design_workspace", "design_tokens", "design_to_code"], + evidence: [ + { + source: "user_override", + target: "design", + note: "Pinned by config override", + }, + ], + }, + ]; + const projections: AdapterProjectionResult[] = [ + { + namespace: "pencil", + adapterId: "gateway", + bucket: "general", + confidence: 1, + reason: "namespace override", + source: "adapter_override", + }, + ]; + + const result: StatusResult = { + upstreams: [ + makeUpstream({ name: "pencil", status: "connected", toolCount: 7 }), + ], + routers: [], + classifications, + adapterProjection: { + adapterId: "gateway", + projections, + }, + }; + + const output = stripAnsi(formatStatus(result, { verbose: true })); + + expect(output).toContain("Namespace Classification"); + expect(output).toContain("pencil"); + expect(output).toContain("design_workspace"); + expect(output).toContain("source=user_override"); + expect(output).toContain( + "facets: design_workspace, design_tokens, design_to_code", + ); + expect(output).toContain("projection[gateway]: general"); + }); });