diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 9db21ca..cf5d526 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -218,9 +218,18 @@ jobs: done echo "::error::nuget.org still does not serve ${ID} ${VERSION}"; exit 1 + # Pinned and checksum-verified like the SHA-pinned actions: this job holds the registry + # OIDC token. linux_amd64 only, since the job runs on ubuntu-latest. To bump: take the + # mcp-publisher_linux_amd64.tar.gz line from the new release's registry__checksums.txt + # (https://github.com/modelcontextprotocol/registry/releases). - name: Download MCP Publisher + env: + MCP_PUBLISHER_VERSION: v1.8.1 + MCP_PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc run: | - curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar -xz -f - mcp-publisher + curl -fsSL -o mcp-publisher.tar.gz "https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz" + echo "${MCP_PUBLISHER_SHA256} mcp-publisher.tar.gz" | sha256sum -c - + tar -xzf mcp-publisher.tar.gz mcp-publisher chmod +x mcp-publisher - name: Publish to MCP Registry diff --git a/CLAUDE.md b/CLAUDE.md index 13db20c..193e591 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,8 +46,8 @@ Release/publish process (`npm run release -- ` → PR → GitHub Release w ### Data pipeline (build time, `scripts/`) - `nuget/WpfDocs.csproj` pins the Infragistics Trial package version (currently 26.1.21) and is the single place to bump it. `docs:restore` fills `nuget/packages/`. -- `scripts/type-extractor/Program.cs` loads every Infragistics DLL into one `AssemblyLoadContext` and writes `nuget/type-info.json` — a flat map of `"Type.Prop" → {typeName,isNullable,isEnum,enumValues}` plus `"Type.__baseType" → "Base"`. This is the only source of property types, enum values, and inheritance; XML docs alone give just summaries. -- `scripts/build-api.ts` parses the NuGet `*.xml` doc files (split-based chunking, not regex-over-whole-file, to avoid backtracking on multi-MB inputs), merges `type-info.json`, then `enrichWithInheritance()` walks `__baseType` chains (cycle-guarded) copying parent members onto children tagged with `declaredOn`. Types are filtered by `EXCLUDE_SUFFIXES`/`EXCLUDE_CONTAINS`/`EXCLUDE_PACKAGES`; members with summary `"internal"` are dropped. When two types share a short name, the one with more documented members wins and the rest are recorded in `alternates`. `ASSEMBLY_XMLNS` maps assembly → xmlns URI/prefix; anything unlisted gets `http://schemas.infragistics.com/xaml` / `ig`. `namespaces.json` is only `Xam*`-prefixed types. `TFM_PREF` and `byVersionDesc` pick one lib folder per package. +- `scripts/type-extractor/Program.cs` loads every Infragistics DLL into one `AssemblyLoadContext` and writes `nuget/type-info.json` — a flat map of `"Assembly|Ns.Type.Prop" → {typeName,isNullable,isEnum,enumValues}` (instance and static properties, plus static `ResourceKey`/`RoutedCommand` fields marked `isField`; keyed by full name because same-named types overwrote each other's entries), `"__baseTypes"` (`"Assembly|Ns.Type" → "Assembly|Ns.Base"`, generic bases as their definition), `"__xaml"` (`"Assembly|Ns.Type" → {xmlns, prefix, ns, xamlName?}`), `"__nonPublicTypes"` (internal / private nested types reflection positively sees as non-public; a type that can't be loaded is never listed) and `"__nonPublicMembers"` (`"Assembly|Ns.Type.Member"` for members of public types that are internal / private / private protected — only when no member of that name on that type is public or protected). The xmlns comes from System.Xaml's `XamlSchemaContext` over the DLLs' own `XmlnsDefinition`/`XmlnsPrefix` attributes — the most specific xmlns that resolves to exactly that type and under which no other type of that name (public or not) lives in a covered namespace, else `clr-namespace:` syntax naming the assembly; nested types are `Outer+Inner`. This is the only source of property types, enum values, inheritance and xmlns; XML docs alone give just summaries. +- `scripts/build-api.ts` parses the NuGet `*.xml` doc files (split-based chunking, not regex-over-whole-file, to avoid backtracking on multi-MB inputs), merges `type-info.json` (its regex-parsed strings are JSON-unescaped: the extractor writes `` ` `` and `<` as ```/`<`), then `enrichWithInheritance()` walks the `__baseTypes` chains by assembly + full name (cycle-guarded) over every parsed type — including bases that lost their short name to a same-named type, so XamGrid's `Cell` subclasses still inherit from it — copying parent members onto children tagged with `declaredOn`. Static key/command fields are listed with the properties (`isField`). Types are filtered by `EXCLUDE_SUFFIXES`/`EXCLUDE_CONTAINS`/`EXCLUDE_PACKAGES`, and non-public types (the XML docs also document ~2,000 internal classes such as `ActivePaneManager`) are left out via `__nonPublicTypes` before short-name collisions are resolved, so a public type wins its name. Members are dropped when their summary is `"internal"`, when `__nonPublicMembers` lists them, or when their doc name has `#` (explicit interface implementations). When two types share a short name, the one with more documented members wins — counted as before the non-public member filter, so no name switches type — and the rest are recorded in `alternates`. Each type's `xamlNamespace`/`defaultPrefix` (and `xamlName` for nested types) come from `__xaml`; types it lacks (open generics, non-public types, DLLs left out of XAML resolution) fall back to `clr-namespace:`. `namespaces.json` is only `Xam*`-prefixed types. `TFM_PREF` and `byVersionDesc` pick one lib folder per package. - `scripts/build-docs.ts` converts `.adoc` topics to `docs/{slug}.json` + `docs-index.json`. docs-common topics use `{PlaceholderName}` variables resolved from `docs-common/DocsConfig.xml` for the `wpf` product; only folders in `COMMON_INCLUDE` are taken from docs-common. - `scripts/build-themes.ts` copies `Themes/` (newer ThemeManager family) and `DefaultStyles/` (legacy embedded-BAML `Theme="..."` family) XAML from wpf-resources and records per-file `targetTypes` parsed from `TargetType=` declarations — this is what makes "is component X themeable" lookups content-aware rather than filename-based. - `scripts/build-guard.ts` `assertBuildStep()` is the fail-loud convention: any step whose output could be legitimately zero due to a missing upstream must assert instead of `console.warn`. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 87618e2..cc6d025 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -39,7 +39,7 @@ First run takes 2–5 minutes, almost all of it the NuGet restore and the type e A healthy build ends with these counts (they drift slightly between Infragistics versions): ``` -Registry: 191 Xam* controls → src/data/namespaces.json +Registry: 189 Xam* controls → src/data/namespaces.json Search index: 6907 types → src/data/search-index.json Topics written : 2678 theme-index.json: 8 newer themes, 45 legacy style folders, 822 XAML files total. @@ -213,7 +213,7 @@ Before the first workflow run, create the three `NUGET_FEED_*` repository secret Published by the `publish-mcp-registry` job of *NuGet publish*, after `publish-nuget` and only once nuget.org serves the version (the job waits for indexing). Never for prereleases — the registry has no dist-tag equivalent, so publishing a beta there would make it the current version; the job is skipped for any version containing `-`. -The registry hosts metadata only. The namespace `io.github.Infragistics-Developer-Tools/*` is claimed automatically when `mcp-publisher login github-oidc` runs from this repository's Actions — nothing to configure. It verifies NuGet ownership by finding `mcp-name: io.github.Infragistics-Developer-Tools/wpf-mcp` in the package README (an HTML comment at the top of `server/README.md`); `validate:package` fails the build if the packed README lacks it. The nupkg also carries `server.json` as `.mcp/server.json`, which nuget.org and MCP clients read for configuration. The workflow downloads a pinned `mcp-publisher` (`MCP_PUBLISHER_VERSION` in `nuget-publish.yml`); `mcp-publisher validate` checks `server.json` against the live registry without publishing. By hand: `mcp-publisher login github && mcp-publisher publish` from the release commit (single binary from the [registry releases](https://github.com/modelcontextprotocol/registry/releases)). +The registry hosts metadata only. The namespace `io.github.Infragistics-Developer-Tools/*` is claimed automatically when `mcp-publisher login github-oidc` runs from this repository's Actions — nothing to configure. It verifies NuGet ownership by finding `mcp-name: io.github.Infragistics-Developer-Tools/wpf-mcp` in the package README (an HTML comment at the top of `server/README.md`); `validate:package` fails the build if the packed README lacks it. The nupkg also carries `server.json` as `.mcp/server.json`, which nuget.org and MCP clients read for configuration. The workflow downloads a pinned `mcp-publisher`, currently v1.8.1 (`MCP_PUBLISHER_VERSION` in `nuget-publish.yml`), and checks it against `MCP_PUBLISHER_SHA256` before running it; to bump, set both from the new release's `registry__checksums.txt` (the `mcp-publisher_linux_amd64.tar.gz` line); `mcp-publisher validate` checks `server.json` against the live registry without publishing. By hand: `mcp-publisher login github && mcp-publisher publish` from the release commit (single binary from the [registry releases](https://github.com/modelcontextprotocol/registry/releases)). ### npm (retired) @@ -241,25 +241,35 @@ A version can never be pushed twice, so a rerun of the workflow for a tag that a ### Why reflection at build time -Infragistics WPF NuGet packages ship two files per assembly: `InfragisticsWPF.*.dll` and the compiler-generated `InfragisticsWPF.*.xml` doc comments. The XML gives summaries but no property types, enum values or inheritance; only reflection on the DLL does. So `scripts/type-extractor/Program.cs` loads every Infragistics DLL into one `AssemblyLoadContext`, walks all public types and writes `nuget/type-info.json` — a flat map of `"Type.Prop" → {typeName, isNullable, isEnum, enumValues}` plus `"Type.__baseType" → "Base"`. `scripts/build-api.ts` then merges that with the XML docs. No reflection happens at query time; a tool call is a JSON file read. +Infragistics WPF NuGet packages ship two files per assembly: `InfragisticsWPF.*.dll` and the compiler-generated `InfragisticsWPF.*.xml` doc comments. The XML gives summaries but no property types, enum values or inheritance; only reflection on the DLL does. So `scripts/type-extractor/Program.cs` loads every Infragistics DLL into one `AssemblyLoadContext`, walks all public types and writes `nuget/type-info.json` — a flat map of `"Assembly|Namespace.Type.Prop" → {typeName, isNullable, isEnum, enumValues}` (static properties and static `ResourceKey`/`RoutedCommand` fields included, the latter with `isField`), plus `__baseTypes`, `__xaml`, `__nonPublicTypes` and `__nonPublicMembers`, all keyed by assembly + full name so types sharing a short name can't overwrite each other (with short-name keys, the XGrid `FilterOperand.Icon`, a `String`, replaced the XamGrid one's `DataTemplate` on 49 pages). `scripts/build-api.ts` then merges that with the XML docs. + +The xmlns of each type is resolved here, not guessed: the extractor reads every DLL's `XmlnsDefinition`/`XmlnsPrefix` attributes and asks System.Xaml's `XamlSchemaContext` — the rules the WPF markup compiler applies — which xmlns resolves to exactly that type. When a namespace has several (e.g. `http://infragistics.com/Themes` and the catch-all `http://schemas.infragistics.com/xaml/wpf`), the one mapping the fewest namespaces wins. Types no Infragistics xmlns reaches get `clr-namespace:Ns;assembly=Asm`, with a unique `ig…` prefix; nested types get `xamlName` `Outer+Inner`. So do types with a rival under the xmlns: another top-level type of the same name, public or internal, in a namespace and DLL that xmlns also covers. `XamlSchemaContext` ignores internal types and settles same-named public ones by assembly order, but the WPF markup compiler does neither: XamDataTree's public `NodesManager` under `http://schemas.infragistics.com/xaml` failed with MC3064 because XamDiagram has an internal `NodesManager` under the same xmlns, and the RichTextEditor/SyntaxEditor twins depend on which package the app references (13 indexed types). A DLL whose Infragistics references aren't in the set (the .NET Framework-only Exchange connector) is left out of resolution, because `XamlSchemaContext` throws on every lookup otherwise. Measured on 26.1.21: all 5,180 public non-generic indexed types resolve to the exact type, and a WPF build that uses every one of them with its generated xmlns compiles with no errors (the previous per-assembly table got 1,984 of them wrong). No reflection happens at query time; a tool call is a JSON file read. ### build-api.ts specifics - XML files are parsed with split-based chunking, not a regex over the whole multi-MB file, and members are bucketed into a `Map` (O(n)). -- `enrichWithInheritance()` walks `__baseType` chains (cycle-guarded) and copies parent members onto children tagged with `declaredOn`. This is why `XamDataGrid` shows dozens of properties although it declares few directly. +- `type-info.json`'s per-member entries are read with regexes, so the captured strings are JSON-unescaped (System.Text.Json writes `` ` `` and `<` as ```/`<`); without that, generic names never matched and `Action` reached agents as `Action`. +- `enrichWithInheritance()` walks `__baseTypes` chains by assembly + full name (cycle-guarded) and copies parent members onto children tagged with `declaredOn`. It looks each base up by full name among every parsed type, not just the pages, so a same-named type (chart `Axis` vs Excel `Axis`) can't leak members in, and a base that lost its short name still passes its members on (XamGrid's `Cell`, whose 11 subclasses had no members from it; 29 types in all). Generic bases (`DataPresenterResourceSet`1`, which holds the theme classes' static styles) are linked through their definition. This is why `XamDataGrid` shows dozens of properties although it declares few directly. +- Static `ResourceKey`/`RoutedCommand` fields (`F:` entries marked `isField` by the extractor — brush keys, commands) are listed with the properties; other fields (enum values, `DependencyProperty` identifiers) are not. - Types matching `EXCLUDE_SUFFIXES` / `EXCLUDE_CONTAINS` / `EXCLUDE_PACKAGES` are dropped, as are members whose summary is literally `internal`. -- When two types share a short name, the one documenting more members wins the file name; the others are listed in its `alternates`. -- `ASSEMBLY_XMLNS` maps assembly → xmlns URI/prefix; unlisted assemblies get `http://schemas.infragistics.com/xaml` / `ig`. `namespaces.json` contains only `Xam*`-prefixed types. +- Non-public types are dropped too. The XML docs also document internal and private nested classes (`ActivePaneManager`, `XamFunnelController`, …) that no app can use — on 26.1.21 they were 1,653 of the 6,907 pages, and search ranked some first ("active pane" → `ActivePaneManager`). The extractor lists the types reflection positively sees as non-public (`__nonPublicTypes`; a type it can't load is never listed, so a load failure can't hide public API), and `build-api.ts` skips them before short-name collisions are resolved, so 89 names that an internal class used to win now document the public class (`DashboardTileGroupDescription`). No public type inherits from a non-public one (C# forbids it), so no inherited members are lost. +- Non-public members of public types are dropped the same way. The extractor lists the member names that reflection sees only as internal, private or private protected on that type (`__nonPublicMembers`; a name with any public or protected overload stays, and nested public types are covered), and explicit interface implementations are recognized by the `#` in their doc name (`System#Collections#IList#Add`). On 26.1.21 that is 2,171 documented members, 9,006 entries across the pages once inherited copies count (`ValueEditor.InternalIsInitialized` on `ValueEditor` and its 13 subclasses, the `XamDockManager.FlyoutOpening` event). Checked by reflection against the DLLs, no internal or private member is left on any page; the check can't load `ExchangeScheduleDataConnector`, whose DLL is .NET Framework-only. +- When two types share a short name, the one documenting more members wins the file name; the others are listed in its `alternates`. The count is taken before non-public members are dropped: with the smaller count, `Cell`, `FieldChooser` and `RecordFilterCollection` would switch from the DataPresenter types to the XamGrid / XamPivotGrid / DataManager ones. +- `xamlNamespace`/`defaultPrefix` (and `xamlName` for nested types) come from the extractor's `__xaml`; types it has no entry for (open generics, non-public types, DLLs left out of XAML resolution) fall back to `clr-namespace:` syntax, and the build logs how many. `namespaces.json` contains only `Xam*`-prefixed types. ### `get_wpf_api_reference` size guard Because inherited members are copied onto every subclass, a few hundred types (mostly charts and annotation layers) rendered to 25–72K characters, and clients like Claude Code save results that large to a file instead of returning them inline. The C# `get_wpf_api_reference` therefore compacts any response over `MaxApiReferenceChars` (25,000, in `ComponentTools.cs`), taking the first of these levels whose output, note included, fits: -1. Events and methods are listed by name only; properties keep full detail. Properties go last because they are what XAML needs, and on the grids nearly all of them are inherited (`XamDataGrid`: 1 of 74 is its own), so compacting inherited members first would hide exactly those. Skipped when the response has no properties (`kind: "events"`/`"methods"`). -2. Inherited members are listed by name only, one line per "inherited from" section (chosen over name + type: at a 25K threshold name-only gives `XamCategoryChart` 19.8K and leaves 8 types over the limit, while name + type gives 26.2K and leaves 12). -3. All members are listed by name only (6 types that declare 250+ members themselves, e.g. `Series`, `XamDomainChart`). +1. Events and methods are listed by name only; properties keep full detail. Properties go last because they are what XAML needs, and on the grids nearly all of them are inherited (`XamDataGrid`: 1 of 70 is its own), so compacting inherited members first would hide exactly those. Skipped when the response has no properties (`kind: "events"`/`"methods"`). +2. Inherited members are listed by name only, one line per "inherited from" section (chosen over name + type when the guard was added: at a 25K threshold name-only gave `XamCategoryChart` 19.8K and left 8 types over the limit, while name + type gave 26.2K and left 12). +3. All members are listed by name only (9 types, among them `Series` and `XamDomainChart`, which declare 402 and 346 members themselves, and the four `…BrushKeys` classes). + +Section headings always stay, so every member name and its declaring type remain visible. A compacted response says so and points to the optional `member` parameter, a case-insensitive name substring that returns only the matching members, inherited ones included, in full detail. The guard applies to `member`-filtered responses as well, so a very broad filter can't bypass it. Measured over all 5,343 public types of the Infragistics 26.1.21 data (re-measure after a data bump): the largest full response is 116,994 chars (`RibbonBrushKeys`, whose keys are listed as static fields) and the largest after the guard 24,678 (`TimeXAxis`); p99 56,929 → 17,694. 191 types are compacted (9 at level 1, among them `XamDataGrid` and `XamTreeGrid` at 18.3K with every property in full; 173 at level 2, among them the charts; 9 at level 3) and all others render in full (`XamGrid` 23.5K). + +### `get_wpf_theme_resource` paging -Section headings always stay, so every member name and its declaring type remain visible. A compacted response says so and points to the optional `member` parameter, a case-insensitive name substring that returns only the matching members, inherited ones included, in full detail. The guard applies to `member`-filtered responses as well, so a very broad filter can't bypass it. Measured over all 6,907 types of the Infragistics 26.1.21 data (re-measure after a data bump): max 71,686 → 24,863 chars, p99 58,954 → 16,717; 211 types are compacted (32 at level 1, among them `XamDataGrid` 19.0K, `XamTreeGrid` 19.0K and `XamGrid` 14.9K with every property in full; 173 at level 2, among them the charts; 6 at level 3) and all others render byte-identically to before. +Theme files used to be cut at 24,000 characters, and 436 of the 822 (26.1.21) are larger — up to 659K (`RibbonGeneric.xaml`) — so agents fetched them from GitHub instead. Now files up to 24,000 characters come back whole (byte-identical to before); larger ones return a contents overview (pages and the `x:Key`/`TargetType` of the entries starting on each; for the 14 files with 700+ entries just the first and last per page). `page: n` returns page n of 23,000 characters of XAML, and `find: ""` returns the top-level entries whose `x:Key`, `TargetType` or element name contains it, in full, wrapped in the file's own root element (paged the same way when large). `server/Tools/XamlPages.cs` splits a file at its top-level elements (with their leading comments and indentation) using `XmlTextReader` with `Namespaces = false` (three DataPresenter files use an undeclared prefix); an element bigger than a page continues on the next at a line break, and the 76 comment-only dictionaries are paged by line. Verified over all files: every page fits in 25K, the pages concatenate to the exact file (2,674 pages), and `find` results — single- and multi-page — compile as WPF XAML. ### Two runtimes, one data set @@ -269,7 +279,7 @@ Section headings always stay, so every member name and its declaring type remain | File | Size | Loaded | Purpose | |---|---|---|---| -| `dist/data/namespaces.json` | ~50 KB | at startup | 191 Xam* control registry | +| `dist/data/namespaces.json` | ~50 KB | at startup | 189 Xam* control registry | | `dist/data/search-index.json` | ~5 MB | at startup | compact index (`n,s,a,p,m` keys): name, summary, member names | | `dist/data/docs-index.json` | ~1 MB | at startup | topic slugs, titles, control names, summaries | | `dist/data/theme-index.json` | small | at startup | theme/style files with parsed `targetTypes` | diff --git a/README.md b/README.md index 6d88a6e..08e17da 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # wpf-mcp -MCP server for **Infragistics Ultimate UI for WPF** — component registry, XAML namespace lookup, full API reference with property types and enum values, keyword search across 6,900+ types, and named-theme setup with palette re-coloring. +MCP server for **Infragistics Ultimate UI for WPF** — component registry, XAML namespace lookup, full API reference with property types and enum values, keyword search across 5,300+ public types, and named-theme setup with verified recolor steps. Distributed as a NuGet `dotnet tool`, [`Infragistics.Wpf.Mcp`](https://www.nuget.org/packages/Infragistics.Wpf.Mcp) — currently in **beta** (`0.1.0-beta.0`). From the first stable release it will also be listed in the [MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.Infragistics-Developer-Tools/wpf-mcp`. @@ -10,13 +10,13 @@ All tools are read-only (`readOnlyHint: true`, `openWorldHint: false`) — none | Tool | Description | |---|---| -| `list_wpf_components` | List all 191 Xam* controls with canonical XAML namespace URIs, NuGet packages, and descriptions. Always call this first before writing XAML. | +| `list_wpf_components` | List all 189 Xam* controls with canonical XAML namespace URIs, NuGet packages, and descriptions. Always call this first before writing XAML. | | `get_wpf_project_scaffold` | Generate ready-to-run `dotnet new` + `dotnet add package` + `dotnet restore` commands and xmlns declarations for a new WPF project. Pass component names resolved via `list_wpf_components`. | -| `search_wpf_api` | Search across all 6,900+ types by keyword — matches type names, summaries, and member names. Use when you don't know the exact type name. | +| `search_wpf_api` | Search across all 5,300+ public types by keyword — matches type names, summaries, and member names. Use when you don't know the exact type name. | | `get_wpf_api_reference` | Full API reference for any type: properties with types and enum values, events, methods, and inherited members grouped by base class. Large types are compacted to stay under ~25K characters (events and methods first, then inherited members, are listed by name only); the optional `member` filter (case-insensitive name substring, e.g. `"YAxis"`) returns full details for just the members you need. | | `setup_wpf_theme` | List available named themes and the raw XAML files backing them, covering both theming mechanisms (legacy embedded-BAML `Theme="..."` and newer `ThemeManager`). Returns ready-to-paste apply steps. Call first for any theming task. | -| `get_wpf_theme_resource` | Retrieve the full raw XAML of one theme/style resource-dictionary file (real `Style`/`ControlTemplate`/brush definitions) to copy and override. Use the exact `path` from `setup_wpf_theme`. | -| `get_wpf_theme_palette` | Return a newer-family (ThemeManager) theme's centralized color/brush palette — exact resource keys, values, and a ready-to-merge override skeleton — for re-coloring a theme without creating a new one. | +| `get_wpf_theme_resource` | Retrieve the full raw XAML of one theme/style resource-dictionary file (real `Style`/`ControlTemplate`/brush definitions) to copy and override. Large files come in pages that concatenate to the exact file; `find` returns single styles, templates or brushes in full. Use the exact `path` from `setup_wpf_theme`. | +| `get_wpf_theme_palette` | Return a newer-family (ThemeManager) theme's color/brush palette — exact resource keys and values — plus verified steps to recolor that theme (control-level overrides or an extended theme) without creating a new one. | | `search_wpf_docs` | Search 2,600+ how-to documentation topics by keyword and/or control name — layouts, styling, data binding, filtering/sorting/grouping, exporting, performance, known issues, etc. | | `get_wpf_doc` | Full text (including XAML samples) of one documentation topic by slug. Use the `slug` from a `search_wpf_docs` result. | @@ -90,12 +90,20 @@ Press `Ctrl+C` to stop it. To try the tools without an AI client, open it in the } ``` -**Claude Code** — from a terminal: +**Claude Code** (CLI or VS Code extension) — add `.mcp.json` to your project root: -```bash -claude mcp add infragistics-wpf -- wpf-mcp +```json +{ + "mcpServers": { + "infragistics-wpf": { + "command": "wpf-mcp" + } + } +} ``` +Approve the server when Claude Code asks, then type `/mcp` in the chat to check that `infragistics-wpf` is connected. If the `claude` command is on your `PATH` (the VS Code extension doesn't add it), you can run `claude mcp add infragistics-wpf -- wpf-mcp` instead. + To use `dnx` instead of a global install, set `"command": "dnx"` and `"args": ["Infragistics.Wpf.Mcp@0.1.0-beta.0", "--yes"]` (Claude Code: `claude mcp add infragistics-wpf -- dnx Infragistics.Wpf.Mcp@0.1.0-beta.0 --yes`). Add `"--debug"` to `args` for any client to log every tool call/response to `wpf-mcp.log` in your system temp folder (override with the `WPF_MCP_LOG` environment variable) — see [Troubleshooting](#troubleshooting). To run from a source checkout instead, build it first (see [DEVELOPMENT.md](DEVELOPMENT.md)) and point `command`/`args` at `dotnet` and `path/to/wpf-mcp/server/bin/Debug/net10.0/wpf-mcp.dll`. @@ -105,7 +113,7 @@ To run from a source checkout instead, build it first (see [DEVELOPMENT.md](DEVE - **`infragistics.wpf.mcp is not found in NuGet feeds`** — the command is missing `--prerelease` (or an exact `@0.1.0-beta.0` version); see [Installation](#installation). - **`Settings file 'DotnetToolSettings.xml' was not found in the package`** — the active .NET SDK is older than 10 (check `dotnet --version`; a `global.json` in the current folder can pin an older one). Install the .NET 10 SDK. - **Which Infragistics version is the data from?** The server prints it to stderr on startup: `Infragistics WPF MCP server 0.1.0-beta.0 ready (data: Infragistics 26.1.21, built )`. Every release regenerates the data from the version pinned in [`nuget/WpfDocs.csproj`](nuget/WpfDocs.csproj). -- **Run the server with `--debug`** (add it to your MCP client config's `args`, see above) to log every tool call's input/output/timing to `wpf-mcp.log` in your system temp folder — useful for reproducing an agent's exact tool-calling sequence after the fact. The exact path is printed to stderr on startup, and can be overridden with the `WPF_MCP_LOG` environment variable. +- **Run the server with `--debug`** (add it to your MCP client config's `args`, see above) to log every tool call's input/output/timing to `wpf-mcp.log` in your system temp folder — useful for reproducing an agent's exact tool-calling sequence after the fact. The exact path is printed to stderr on startup, and can be overridden with the `WPF_MCP_LOG` environment variable. `--debug` also turns on the MCP SDK's per-request logging on stderr, which is otherwise limited to warnings and errors. - **`wpf-mcp` is not found** after `dotnet tool install -g` — the global tools folder (`%USERPROFILE%\.dotnet\tools` on Windows, `~/.dotnet/tools` elsewhere) is not on the `PATH` of the process that launches the client. Restart the client, or use the full path as `command`. - **A tool call succeeded but the answer looks wrong or a control seems missing** — this is usually stale/outdated data from Infragistics' own NuGet package (summaries, base types) rather than a bug in this server. Cross-check against the pinned version above before assuming the MCP itself is at fault. - **A tool returns "no topics found" / "no themes found" for everything** on a source build — the data pipeline didn't complete. Re-run `npm run build:all` and read the output; it either reports real counts or aborts with a 🚨 banner saying exactly what's missing. Published packages are validated against these counts before release, so this can't happen with a NuGet install. diff --git a/scripts/build-api.ts b/scripts/build-api.ts index 4343d0a..648d7d6 100644 --- a/scripts/build-api.ts +++ b/scripts/build-api.ts @@ -21,31 +21,6 @@ const PACKAGES_DIR = join(ROOT, 'nuget', 'packages'); const API_OUT_DIR = join(ROOT, 'src', 'data', 'api'); const NS_OUT_FILE = join(ROOT, 'src', 'data', 'namespaces.json'); -// ── XAML namespace map ──────────────────────────────────────────────────────── -// Maps assembly name → XAML xmlns URI + recommended prefix. -// Sourced from [assembly: XmlnsDefinition] attributes; stable across versions. -const ASSEMBLY_XMLNS: Record = { - 'Infragistics.WPF.DataPresenter': { xmlns: 'http://infragistics.com/DataPresenter', prefix: 'igDP' }, - 'InfragisticsWPF.DataPresenter': { xmlns: 'http://infragistics.com/DataPresenter', prefix: 'igDP' }, - 'Infragistics.WPF.DockManager': { xmlns: 'http://infragistics.com/DockManager', prefix: 'igDock' }, - 'InfragisticsWPF.DockManager': { xmlns: 'http://infragistics.com/DockManager', prefix: 'igDock' }, - 'Infragistics.WPF.Ribbon': { xmlns: 'http://infragistics.com/Ribbon', prefix: 'igRibbon' }, - 'InfragisticsWPF.Ribbon': { xmlns: 'http://infragistics.com/Ribbon', prefix: 'igRibbon' }, - 'Infragistics.WPF.Editors': { xmlns: 'http://infragistics.com/Editors', prefix: 'igEditors' }, - 'InfragisticsWPF.Editors': { xmlns: 'http://infragistics.com/Editors', prefix: 'igEditors' }, - 'Infragistics.WPF.Documents.Excel': { xmlns: 'http://infragistics.com/Excel', prefix: 'igExcel' }, - 'InfragisticsWPF.Documents.Excel': { xmlns: 'http://infragistics.com/Excel', prefix: 'igExcel' }, - 'Infragistics.WPF.DataPresenter.ExcelExporter': { xmlns: 'http://infragistics.com/ExcelExporter', prefix: 'igExcelExporter' }, - 'InfragisticsWPF.DataPresenter.ExcelExporter': { xmlns: 'http://infragistics.com/ExcelExporter', prefix: 'igExcelExporter' }, - 'Infragistics.WPF.DataPresenter.WordWriter': { xmlns: 'http://infragistics.com/WordWriter', prefix: 'igWordWriter' }, - 'InfragisticsWPF.DataPresenter.WordWriter': { xmlns: 'http://infragistics.com/WordWriter', prefix: 'igWordWriter' }, - 'Infragistics.WPF.Reporting': { xmlns: 'http://infragistics.com/Reporting', prefix: 'igRep' }, - 'InfragisticsWPF.Reporting': { xmlns: 'http://infragistics.com/Reporting', prefix: 'igRep' }, - 'Infragistics.WPF.OutlookBar': { xmlns: 'http://infragistics.com/OutlookBar', prefix: 'igOB' }, - 'InfragisticsWPF.OutlookBar': { xmlns: 'http://infragistics.com/OutlookBar', prefix: 'igOB' }, -}; -const DEFAULT_XMLNS = { xmlns: 'http://schemas.infragistics.com/xaml', prefix: 'ig' }; - // ── Type name exclusion list ────────────────────────────────────────────────── const EXCLUDE_SUFFIXES = [ 'AutomationPeer', 'EventArgs', 'Converter', 'Exception', 'Factory', @@ -55,7 +30,7 @@ const EXCLUDE_CONTAINS = ['Internal', '<>', 'AnonymousType']; // XamXGrid is public and used internally for xplat components, but may // become customer-facing later, so this is a plain omission, not a "deprecated" label. -const EXCLUDE_PACKAGES = ['infragistics.wpf.controls.grids.xamxgrid.trial']; +const EXCLUDE_PACKAGES = ['infragistics.wpf.controls.grids.xamxgrid.trial']; // compared lowercase function shouldExclude(typeName: string): boolean { return ( @@ -127,6 +102,19 @@ const TFM_PREF = ['net10.0-windows7.0', 'net9.0-windows7.0', 'net8.0-windows7.0' interface XmlSource { xmlPath: string; packageId: string } +// nuget/packages/ is NuGet's global-packages layout, whose folder names are the lowercased ID. +// The .nuspec keeps the canonical casing ("Infragistics.WPF.DataGrids.Trial"), which is what +// agents copy into a user's .csproj via `dotnet add package`. +function readPackageId(versionDir: string, folderName: string): string { + const nuspec = join(versionDir, `${folderName}.nuspec`); + const id = existsSync(nuspec) ? /\s*([^<\s]+)\s*<\/id>/.exec(readFileSync(nuspec, 'utf8'))?.[1] : undefined; + assertBuildStep(id?.toLowerCase() === folderName, + `${nuspec}: expected an matching the folder name "${folderName}", got ${id ? `"${id}"` : 'none'} — ` + + `partial/failed restore? Re-run: npm run docs:restore` + ); + return id!; +} + function discoverXmlFiles(): XmlSource[] { if (!existsSync(PACKAGES_DIR)) { console.error('nuget/packages/ not found. Run: npm run docs:restore'); @@ -143,6 +131,7 @@ function discoverXmlFiles(): XmlSource[] { const versions = readdirSync(pkgDir).sort(byVersionDesc); if (!versions.length) continue; const versionDir = join(pkgDir, versions[0]); + const packageId = readPackageId(versionDir, pkgName); for (const tfm of TFM_PREF) { const libDir = join(versionDir, 'lib', tfm); @@ -153,7 +142,7 @@ function discoverXmlFiles(): XmlSource[] { const xmlPath = join(libDir, f); if (!seen.has(xmlPath)) { seen.add(xmlPath); - sources.push({ xmlPath, packageId: pkgName }); + sources.push({ xmlPath, packageId }); } } break; // use best matching TFM per package @@ -179,6 +168,8 @@ export interface ApiMemberEntry { isEnum?: boolean; enumValues?: string[]; declaredOn?: string; + /** A static ResourceKey/RoutedCommand field (used via {x:Static}), listed with the properties. */ + isField?: boolean; } export interface AlternateType { @@ -194,6 +185,8 @@ export interface ApiEntry { xamlNamespace: string; defaultPrefix: string; dotnetNamespace: string; + /** Set only for nested types: their XAML name, e.g. "DataPresenterRoyalDark+Locator". */ + xamlName?: string; summary: string; remarks: string; /** Immediate base type name, if it is also an indexed Infragistics type. */ @@ -220,8 +213,12 @@ interface TypeInfoEntry { isNullable: boolean; isEnum: boolean; enumValues?: string[]; + isField?: boolean; } +/** XAML namespace of one type, resolved by the type extractor (see "Phase 3" in type-extractor/Program.cs). */ +interface XamlTypeInfo { xmlns: string; prefix: string; ns: string; xamlName?: string } + function loadTypeInfo(): Record { const path = join(ROOT, 'nuget', 'type-info.json'); assertBuildStep(existsSync(path), @@ -231,29 +228,51 @@ function loadTypeInfo(): Record { ); const raw = readFileSync(path, 'utf-8'); const result: Record = {}; + // The regexes capture raw JSON string contents, and System.Text.Json escapes ` < > + as ` etc.: decode them, + // or "DataPresenterResourceSet`1" never matches and "Action" reaches agents as "Action". + const decode = (s: string): string => JSON.parse(`"${s}"`); - // Parse __baseType string values e.g. "XamCategoryChart.__baseType":"XamXYChart" - const baseRe = /"([^"]+\.__baseType)":"([^"]+)"/g; - let bm: RegExpExecArray | null; - while ((bm = baseRe.exec(raw)) !== null) result[bm[1]] = bm[2]; - - // Parse property type entries - const re = /"([^"]+)":\{"typeName":"([^"]+)","isNullable":(true|false),"isEnum":(true|false)(?:,"enumValues":\[([^\]]*)])?\}/g; + // Parse property (and static field) type entries + const re = /"([^"]+)":\{"typeName":"([^"]+)","isNullable":(true|false),"isEnum":(true|false)(?:,"enumValues":\[([^\]]*)])?(?:,"isField":(true))?\}/g; let m: RegExpExecArray | null; while ((m = re.exec(raw)) !== null) { const enumValuesRaw = m[5]; - result[m[1]] = { - typeName: m[2], + const entry: TypeInfoEntry = { + typeName: decode(m[2]), isNullable: m[3] === 'true', isEnum: m[4] === 'true', enumValues: enumValuesRaw - ? enumValuesRaw.replace(/"/g, '').split(',').filter(Boolean) + ? enumValuesRaw.replace(/"/g, '').split(',').filter(Boolean).map(decode) : undefined, }; + if (m[6]) entry.isField = true; + result[decode(m[1])] = entry; } return result; } +/** + * The extractor's maps keyed by "Assembly|Namespace.Type" (nested: "…|Namespace.Outer.Inner"), so types that share + * a short name can't overwrite each other: each type's XAML namespace and prefix, its Infragistics base type, and + * the types and members ("…|Namespace.Type.Member") that are not public. + */ +function loadExtractorMaps(): { + xaml: Record; baseTypes: Record; nonPublic: Set; nonPublicMembers: Set; +} { + const parsed = JSON.parse(readFileSync(join(ROOT, 'nuget', 'type-info.json'), 'utf-8')); + const xaml = parsed.__xaml as Record | undefined; + const baseTypes = parsed.__baseTypes as Record | undefined; + const nonPublic = parsed.__nonPublicTypes as string[] | undefined; + const nonPublicMembers = parsed.__nonPublicMembers as string[] | undefined; + assertBuildStep(!!xaml && Object.keys(xaml).length > 0 && !!baseTypes && Object.keys(baseTypes).length > 0 && + !!nonPublic?.length && !!nonPublicMembers?.length, + `nuget/type-info.json has no "__xaml" / "__baseTypes" / "__nonPublicTypes" / "__nonPublicMembers" section — it was ` + + `written by an older type extractor, so every type would get a guessed xmlns, no inherited members, and internal ` + + `classes and members would be indexed as usable. Run: npm run generate:types` + ); + return { xaml: xaml!, baseTypes: baseTypes!, nonPublic: new Set(nonPublic), nonPublicMembers: new Set(nonPublicMembers) }; +} + /** * Second pass: enrich every entry in-memory with inherited Infragistics members. * This walks the full base-type chain generically for ALL indexed types (properties, @@ -263,30 +282,34 @@ function loadTypeInfo(): Record { */ function enrichWithInheritance( entries: Map, - typeInfo: Record + baseTypes: Record, + allTypes: Map ): number { let enriched = 0; + const fullKey = (e: ApiEntry) => `${e.assembly}|${e.dotnetNamespace}.${e.component}`; - for (const [typeName, entry] of entries) { - const immediateBase = typeInfo[`${typeName}.__baseType`]; - if (typeof immediateBase === 'string') entry.baseType = immediateBase; - - if (!(`${typeName}.__baseType` in typeInfo)) continue; + for (const entry of entries.values()) { + const immediateBase = baseTypes[fullKey(entry)]; + if (!immediateBase) continue; + entry.baseType = immediateBase.split('.').pop()!; const knownProps = new Set(entry.properties.map(p => p.name)); const knownEvents = new Set(entry.events.map(e => e.name)); const knownMethods = new Set(entry.methods.map(m => m.name)); - const visited = new Set([typeName]); + const visited = new Set([fullKey(entry)]); let added = false; - let current = typeName; + let current = fullKey(entry); while (true) { - const base = typeInfo[`${current}.__baseType`]; - if (!base || typeof base !== 'string') break; - if (visited.has(base)) break; // cycle guard - visited.add(base); - - const baseEntry = entries.get(base); + const baseKey = baseTypes[current]; + if (!baseKey) break; + if (visited.has(baseKey)) break; // cycle guard + visited.add(baseKey); + + // Looked up by full name among every parsed type, not just the pages: a base that lost its short name to a + // same-named type (XamGrid's Cell; the "Cell" page documents the DataPresenter one) still passes its members on. + const base = baseKey.split('.').pop()!; + const baseEntry = allTypes.get(baseKey); if (!baseEntry) break; for (const p of baseEntry.properties.filter(p => !p.declaredOn && !knownProps.has(p.name))) { @@ -304,7 +327,7 @@ function enrichWithInheritance( knownMethods.add(m.name); added = true; } - current = base; + current = baseKey; } if (added) enriched++; @@ -318,11 +341,19 @@ async function generate() { mkdirSync(API_OUT_DIR, { recursive: true }); const typeInfo = loadTypeInfo(); + const { xaml: xamlTypes, baseTypes, nonPublic, nonPublicMembers } = loadExtractorMaps(); + let skippedNonPublic = 0, skippedNonPublicMembers = 0; + // Prefix per xmlns, for the few types the extractor has no entry for (generics, DLLs left out of XAML resolution). + const prefixByXmlns = new Map(Object.values(xamlTypes).map(x => [x.xmlns, x.prefix])); + const xamlFallbacks: string[] = []; const discovered = discoverXmlFiles(); - const sources = discovered.filter(s => !EXCLUDE_PACKAGES.includes(s.packageId)); + const sources = discovered.filter(s => !EXCLUDE_PACKAGES.includes(s.packageId.toLowerCase())); console.log(`Scanning ${sources.length} XML documentation files...`); const entries = new Map(); + // Every parsed type by "Assembly|Namespace.Type", including those that lose their short name below: the inheritance + // walk follows real base types, which aren't always the type that owns their name. + const allTypes = new Map(); // Entries are keyed by short type name, but short names are not unique across // assemblies. Collisions are resolved case-INsensitively: type files are named after // the key, so on Windows "strings" and "Strings" would otherwise be written to the @@ -330,6 +361,7 @@ async function generate() { // names case-insensitively, so a case-variant twin is unreachable regardless. const keyByLower = new Map(); const memberCounts = new Map(); + const contestCounts = new Map(); const alternates = new Map(); for (let fileIdx = 0; fileIdx < sources.length; fileIdx++) { @@ -343,12 +375,11 @@ async function generate() { const assemblyMatch = xml.match(/\s*([^<]+)<\/name>/); if (!assemblyMatch) { console.log('skip (no assembly)'); continue; } const assemblyName = assemblyMatch[1].trim(); - const xmlnsInfo = ASSEMBLY_XMLNS[assemblyName] ?? DEFAULT_XMLNS; const members = parseAllMembers(xml); // Build lookup maps once per file — O(n) instead of O(n × types) - const typeMembers = new Map(); + const typeMembers = new Map(); const typeList: RawMember[] = []; for (const m of members) { if (m.kind === 'T') { typeList.push(m); continue; } @@ -360,9 +391,10 @@ async function generate() { const lastDot = memberPath.lastIndexOf('.'); if (lastDot < 0) continue; const typeFqn = memberPath.substring(0, lastDot); - if (!typeMembers.has(typeFqn)) typeMembers.set(typeFqn, { props: [], events: [], methods: [] }); + if (!typeMembers.has(typeFqn)) typeMembers.set(typeFqn, { props: [], fields: [], events: [], methods: [] }); const bucket = typeMembers.get(typeFqn)!; if (m.kind === 'P') bucket.props.push(m); + else if (m.kind === 'F') bucket.fields.push(m); // kept below only if the extractor marked it isField else if (m.kind === 'E') bucket.events.push(m); else if (m.kind === 'M' && !m.name.startsWith('#')) bucket.methods.push(m); } @@ -371,35 +403,70 @@ async function generate() { if (!type.fullName.startsWith('Infragistics.')) continue; const typeName = type.name; if (shouldExclude(typeName)) continue; + // The XML docs also document internal / private nested classes, which apps can't use. Leaving them out here, + // before short-name collisions are resolved, also lets a public type win its name (DashboardTileGroupDescription). + if (nonPublic.has(`${assemblyName}|${type.fullName}`)) { skippedNonPublic++; continue; } + // For a nested type this is "Namespace.Outer", which keeps the C# full name right; xamlName carries "Outer+Inner". const dotnetNs = type.fullName.substring(0, type.fullName.lastIndexOf('.')); - const bucket = typeMembers.get(type.fullName) ?? { props: [], events: [], methods: [] }; + const bucket = typeMembers.get(type.fullName) ?? { props: [], fields: [], events: [], methods: [] }; + + let xaml = xamlTypes[`${assemblyName}|${type.fullName}`]; + if (!xaml) { + // No resolved entry (open generics can't be named in XAML; DLLs the extractor left out of XAML resolution): + // clr-namespace syntax, which needs no xmlns mapping. + const xmlns = `clr-namespace:${dotnetNs};assembly=${assemblyName}`; + xaml = { xmlns, prefix: prefixByXmlns.get(xmlns) ?? `ig${dotnetNs.split('.').pop()}`, ns: dotnetNs }; + xamlFallbacks.push(type.fullName); + } + + const withTypeInfo = (m: RawMember) => { + const ti = typeInfo[`${assemblyName}|${type.fullName}.${m.name}`]; + const extra = (ti && typeof ti === 'object') ? ti : {}; + return { name: m.name, summary: m.summary, ...extra }; + }; + // The XML docs also document members no app can call: internal / private ones (ValueEditor.InternalIsInitialized), + // and explicit interface implementations, whose doc names have '#' for '.' ("System#Collections#IList#Add"). + const usable = (m: RawMember) => { + if (m.summary.toLowerCase() === 'internal') return false; + if (m.name.includes('#') || nonPublicMembers.has(`${assemblyName}|${type.fullName}.${m.name.replace(/``\d+$/, '')}`)) { + skippedNonPublicMembers++; + return false; + } + return true; + }; const entry: ApiEntry = { component: typeName, assembly: assemblyName, nugetPackage: packageId, - xamlNamespace: xmlnsInfo.xmlns, - defaultPrefix: xmlnsInfo.prefix, + xamlNamespace: xaml.xmlns, + defaultPrefix: xaml.prefix, dotnetNamespace: dotnetNs, + ...(xaml.xamlName ? { xamlName: xaml.xamlName } : {}), summary: type.summary, remarks: type.remarks, - properties: bucket.props - .filter(m => m.summary.toLowerCase() !== 'internal') - .map(m => { - const ti = typeInfo[`${typeName}.${m.name}`]; - const extra = (ti && typeof ti === 'object') ? ti : {}; - return { name: m.name, summary: m.summary, ...extra }; - }), + // Static key/command fields are listed with the properties: both are used the same way in XAML. + properties: [ + ...bucket.props.filter(usable).map(withTypeInfo), + ...bucket.fields.filter(usable).map(withTypeInfo).filter(f => (f as ApiMemberEntry).isField === true), + ], events: bucket.events - .filter(m => m.summary.toLowerCase() !== 'internal') + .filter(usable) .map(m => ({ name: m.name, summary: m.summary })), methods: bucket.methods - .filter(m => m.summary.toLowerCase() !== 'internal') + .filter(usable) .map(m => ({ name: m.name, summary: m.summary })), }; + allTypes.set(`${assemblyName}|${type.fullName}`, entry); const memberCount = entry.properties.length + entry.events.length + entry.methods.length; + // Short-name contests are still decided by the count from before internal / private members were left out: + // with the new count, Cell, FieldChooser and RecordFilterCollection switched from the DataPresenter types to the + // XamGrid / XamPivotGrid / DataManager ones. + const documented = (ms: RawMember[]) => ms.filter(m => m.summary.toLowerCase() !== 'internal').length; + const contestCount = documented(bucket.props) + entry.properties.filter(p => (p as ApiMemberEntry).isField).length + + documented(bucket.events) + documented(bucket.methods); const candidate: AlternateType = { fullName: type.fullName, nugetPackage: packageId, @@ -412,6 +479,7 @@ async function generate() { entries.set(typeName, entry); keyByLower.set(lower, typeName); memberCounts.set(lower, memberCount); + contestCounts.set(lower, contestCount); } else { // Two different types share this short name. Discovery order is just // package-alphabetical, which surfaces stub types over the real control @@ -420,7 +488,7 @@ async function generate() { const incumbent = entries.get(incumbentKey)!; if (!alternates.has(lower)) alternates.set(lower, []); - if (memberCount > memberCounts.get(lower)!) { + if (contestCount > contestCounts.get(lower)!) { alternates.get(lower)!.push({ fullName: `${incumbent.dotnetNamespace}.${incumbent.component}`, nugetPackage: incumbent.nugetPackage, @@ -430,6 +498,7 @@ async function generate() { entries.set(typeName, entry); keyByLower.set(lower, typeName); memberCounts.set(lower, memberCount); + contestCounts.set(lower, contestCount); } else { alternates.get(lower)!.push(candidate); } @@ -437,6 +506,12 @@ async function generate() { } console.log(`${Date.now() - t0}ms (${members.length} members)`); } + console.log(`Left out ${skippedNonPublic} non-public (internal / private nested) type(s) documented in the XML, and ` + + `${skippedNonPublicMembers} member(s) of public types that are internal / private or explicit interface implementations.`); + const genericFallbacks = xamlFallbacks.filter(n => n.includes('`')).length; + console.log(`XAML namespaces: ${xamlFallbacks.length} type(s) fell back to clr-namespace syntax ` + + `(${genericFallbacks} generic, ${xamlFallbacks.length - genericFallbacks} other${xamlFallbacks.length > genericFallbacks ? `: ${xamlFallbacks.filter(n => !n.includes('`')).slice(0, 8).join(', ')}` : ''})`); + for (const [lower, alts] of alternates) { const winner = entries.get(keyByLower.get(lower)!); if (winner) winner.alternates = alts.sort((a, b) => b.memberCount - a.memberCount); @@ -444,7 +519,14 @@ async function generate() { // Enrich in-memory, then write everything at once console.log(`Enriching inheritance...`); - const enriched = enrichWithInheritance(entries, typeInfo); + const enriched = enrichWithInheritance(entries, baseTypes, allTypes); + + const escaped = [...entries.values()].filter(e => /\\\\u00[0-9A-Fa-f]{2}/.test(JSON.stringify(e))); + assertBuildStep(escaped.length === 0, + `${escaped.length} API entr${escaped.length === 1 ? 'y' : 'ies'} (e.g. ${escaped.slice(0, 3).map(e => e.component).join(', ')}) contain ` + + `JSON escapes such as \\u003C as literal text — a type-info.json string was used without decode(), so agents would ` + + `read "Action\\u003CToolCommand\\u003E" instead of "Action".` + ); console.log(`Writing ${entries.size} API files...`); await Promise.all( diff --git a/scripts/smoke-test.ts b/scripts/smoke-test.ts index 56374d0..0a1494a 100644 --- a/scripts/smoke-test.ts +++ b/scripts/smoke-test.ts @@ -13,7 +13,8 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { existsSync } from 'fs'; +import { existsSync, readFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; @@ -25,6 +26,9 @@ const IS_DOTNET = process.argv.includes('--dotnet'); const TARGET = IS_DOTNET ? { entry: DOTNET_DLL, command: 'dotnet', args: [DOTNET_DLL], hint: 'dotnet build server/' } : { entry: ENTRY, command: process.execPath, args: [ENTRY], hint: 'npm run build' }; +// Top-level heading of a tool response: the C# server uses `##` (clients render `#` as a page-sized +// title in their tool-result panes), the frozen TS server still uses `#`. +const H1 = IS_DOTNET ? '##' : '#'; const EXPECTED_TOOLS = [ 'list_wpf_components', @@ -88,22 +92,79 @@ async function main(): Promise { let r = await call('list_wpf_components', { filter: 'grid' }); let text = textOf(r); check('filter "grid" finds XamDataGrid with its xmlns', !r.isError && text.includes('XamDataGrid') && text.includes('http://infragistics.com/DataPresenter')); + // The xmlns comes from the DLL's own XmlnsDefinition per namespace, not one guess per assembly + // (XamTabControl used to get http://schemas.infragistics.com/xaml, which doesn't compile). + r = await call('list_wpf_components', { filter: 'XamTabControl' }); + check('XamTabControl gets the xmlns its DLL maps it to', !r.isError && textOf(r).includes('xmlns:igWindows="http://infragistics.com/Windows"'), textOf(r).slice(0, 300)); + // Internal classes are not indexed: the XML docs document them too, but no app can use them. + r = await call('list_wpf_components', { filter: 'Funnel' }); + text = textOf(r); + check('internal classes are not listed as controls', !r.isError && text.includes('XamFunnelChart') && !text.includes('XamFunnelController') && !text.includes('XamFunnelConnector'), text.slice(0, 300)); console.log('get_wpf_api_reference'); r = await call('get_wpf_api_reference', { component: 'XamDataGrid', kind: 'all' }); text = textOf(r); - check('XamDataGrid resolves with xmlns and NuGet', !r.isError && text.startsWith('# XamDataGrid') && text.includes('xmlns:igDP=') && text.includes('Infragistics.WPF')); + check('XamDataGrid resolves with xmlns and NuGet', !r.isError && text.startsWith(`${H1} XamDataGrid\n`) && text.includes('xmlns:igDP=') && text.includes('Infragistics.WPF')); check('inherited members are grouped by base type', text.includes('(inherited from `DataPresenterBase`)'), 'no "(inherited from `DataPresenterBase`)" section — enrichWithInheritance did not run'); // kind: properties — small enough to render every member (inherited ones included) in full. r = await call('get_wpf_api_reference', { component: 'XamDataGrid', kind: 'properties' }); check('property types and enum values are attached', /`[A-Za-z.]+` — values: `/.test(textOf(r)), 'no "`Type` — values: `…`" member line — type-info.json was not merged'); + // Theme classes: their xmlns is http://infragistics.com/Themes, and their static styles come from a generic base. + r = await call('get_wpf_api_reference', { component: 'DataPresenterRoyalDark' }); + text = textOf(r); + check('theme class gets the Themes xmlns and its inherited static styles', + !r.isError && text.includes('xmlns:igThemes="http://infragistics.com/Themes"') && text.includes('**CellValuePresenter** `Style`') && text.includes('(inherited from `DataPresenterResourceSet`1`)'), + text.slice(0, 400)); + // Static key/command fields are listed (they used to be dropped: DataPresenterCommands showed 0 of 71 commands). + r = await call('get_wpf_api_reference', { component: 'DataPresenterCommands', kind: 'properties' }); + check('static command fields are listed', !r.isError && textOf(r).includes('**DeleteSelectedDataRecords** `RoutedCommand`'), textOf(r).slice(0, 300)); + // type-info strings are JSON-unescaped (generic type names used to reach agents as Action). + // (C# compacts XamDataChart's inherited properties to names, so ask for the member itself there.) + r = await call('get_wpf_api_reference', IS_DOTNET ? { component: 'XamDataChart', member: 'CommandCompleted' } : { component: 'XamDataChart', kind: 'properties' }); + check('generic type names are not JSON-escaped', !r.isError && textOf(r).includes('`Action`') && !/\\u00[0-9A-F]{2}/.test(textOf(r))); + // Inheritance follows the exact base type: the chart Axis used to show members of the Excel library's ChartObject. + r = await call('get_wpf_api_reference', { component: 'Axis', kind: 'properties' }); + check('chart Axis does not inherit from a same-named type', !r.isError && !textOf(r).includes('ChartObject')); + // Internal classes have no page, and a name an internal class used to win now documents the public class. + r = await call('get_wpf_api_reference', { component: 'ActivePaneManager' }); + check('internal class has no API page', r.isError === true, textOf(r).slice(0, 200)); + r = await call('get_wpf_api_reference', { component: 'DashboardTileGroupDescription' }); + text = textOf(r); + check('public type wins its name over an internal one', + !r.isError && text.includes('`Infragistics.Controls.Dashboards.DashboardTileGroupDescription`') && !text.includes('.Implementation.'), text.slice(0, 400)); + // Members no app can call are not listed: internal ones (the XML docs document XamDockManager's internal + // FlyoutOpening event) and explicit interface implementations ("System#Windows#Controls#Primitives#IScrollInfo#…"). + r = await call('get_wpf_api_reference', { component: 'XamDockManager', kind: 'events' }); + text = textOf(r); + check('internal members are not listed', !r.isError && text.includes('**PaneDragStarting**') && !text.includes('FlyoutOpening'), text.slice(0, 300)); + r = await call('get_wpf_api_reference', { component: 'XamCarouselPanel', kind: 'properties' }); + check('explicit interface implementations are not listed', !r.isError && textOf(r).includes('# XamCarouselPanel') && !textOf(r).includes('IScrollInfo#'), textOf(r).slice(0, 300)); + // Member types are looked up per exact type: the XGrid FilterOperand's Icon (a String) used to overwrite XamGrid's. + r = await call('get_wpf_api_reference', { component: 'DateTimeLastWeekFilterOperand', kind: 'properties' }); + check('member types come from the exact type, not a same-named one', !r.isError && textOf(r).includes('**Icon** `DataTemplate`'), textOf(r).slice(0, 300)); + // A base that lost its short name still passes its members on (XamGrid's Cell; the "Cell" page is the DataPresenter one). + r = await call('get_wpf_api_reference', { component: 'AddNewRowCell', kind: 'properties' }); + text = textOf(r); + check('members are inherited from a base whose name another type owns', !r.isError && text.includes('(inherited from `Cell`)') && text.includes('**IsEditing**'), text.slice(0, 300)); + // A type whose name another DLL's type also has under the same xmlns (XamDiagram's internal NodesManager) would fail + // to compile with that xmlns, so it gets clr-namespace syntax naming its own assembly. + r = await call('get_wpf_api_reference', { component: 'NodesManager' }); + check('a type shadowed under its xmlns gets clr-namespace with its assembly', + !r.isError && textOf(r).includes('clr-namespace:Infragistics.Controls.Menus;assembly=Infragistics.WPF.Controls.Menus.XamDataTree'), textOf(r).slice(0, 300)); + if (IS_DOTNET) { + // C# only: field label and nested-class XAML name are rendered by the C# server. + r = await call('get_wpf_api_reference', { component: 'DataPresenterCommands', member: 'DeleteSelectedDataRecords' }); + check('static fields are labelled', !r.isError && textOf(r).includes('`RoutedCommand` _(static field)_'), textOf(r).slice(0, 400)); + r = await call('get_wpf_api_reference', { component: 'ActionBase' }); + check('nested classes show their XAML name', !r.isError && textOf(r).includes('**XAML name:** `igHelpers:ActionHistory+ActionBase`'), textOf(r).slice(0, 400)); + } if (IS_DOTNET) { // C# only: the TS server has no size guard / member filter. r = await call('get_wpf_api_reference', { component: 'XamCategoryChart' }); text = textOf(r); check('large type is compacted under 25K chars', !r.isError && text.length <= 25_000 && text.includes('_ℹ️ Compacted:') && text.includes('(inherited from `XamXYChart`)\n`'), `${text.length} chars`); // Grids: events/methods go to names first, so the (mostly inherited) properties XAML needs keep full detail. - for (const grid of ['XamDataGrid', 'XamGrid']) { + for (const grid of ['XamDataGrid', 'XamTreeGrid']) { r = await call('get_wpf_api_reference', { component: grid }); text = textOf(r); check(`${grid} compaction keeps full property detail`, @@ -117,6 +178,11 @@ async function main(): Promise { text.slice(0, 300)); r = await call('get_wpf_api_reference', { component: 'XamCategoryChart', member: 'NoSuchMemberXyz' }); check('member filter with no match explains instead of erroring', !r.isError && textOf(r).includes('No members whose name contains "NoSuchMemberXyz"')); + // A full member name must fit the limit: RibbonBrushKeys' static keys run to 74 characters. + r = await call('get_wpf_api_reference', { component: 'XamCategoryChart', member: 'x'.repeat(128) }); + check('member filter accepts 128 characters', !r.isError && textOf(r).includes('No members whose name contains'), textOf(r).slice(0, 200)); + r = await call('get_wpf_api_reference', { component: 'XamCategoryChart', member: 'x'.repeat(129) }); + check('member over 128 characters → isError naming the limit', r.isError === true && textOf(r).includes('max 128 characters'), textOf(r).slice(0, 200)); } r = await call('get_wpf_api_reference', { component: 'xamdatagrid', kind: 'events' }); check('case-insensitive lookup + kind filter', !r.isError && textOf(r).includes('# XamDataGrid')); @@ -125,12 +191,25 @@ async function main(): Promise { console.log('search_wpf_api'); r = await call('search_wpf_api', { query: 'filter', limit: 5 }); - check('keyword search returns ranked results', !r.isError && textOf(r).includes('# WPF API Search')); + check('keyword search returns ranked results', !r.isError && textOf(r).startsWith(`${H1} WPF API Search`)); + if (IS_DOTNET) { + // C# only: the TS server returns no results for limit <= 0 and doesn't enforce max lengths. + r = await call('search_wpf_api', { query: 'filter', limit: 0 }); + check('limit 0 is clamped to 1 result', !r.isError && textOf(r).includes('(1 of '), textOf(r).slice(0, 300)); + r = await call('search_wpf_api', { query: 'filter '.repeat(100) }); + check('over-long query → isError naming the limit', r.isError === true && textOf(r).includes('max 128 characters'), textOf(r).slice(0, 300)); + r = await call('search_wpf_api', { query: ' ' }); + check('blank query → isError', r.isError === true && textOf(r).includes('must not be empty'), textOf(r).slice(0, 300)); + } + // "active pane" used to rank the internal ActivePaneManager first. + r = await call('search_wpf_api', { query: 'active pane', limit: 10 }); + check('search does not return internal classes', !r.isError && !textOf(r).includes('ActivePaneManager'), textOf(r).slice(0, 300)); console.log('get_wpf_project_scaffold'); r = await call('get_wpf_project_scaffold', { components: ['XamDataGrid', 'XamCategoryChart'], projectName: 'SmokeApp', framework: 'net10.0' }); text = textOf(r); check('scaffold has dotnet commands and xmlns', !r.isError && text.includes('dotnet new') && text.includes('dotnet add') && text.includes('xmlns:')); + check('package IDs keep their canonical casing', text.includes('dotnet add package Infragistics.WPF.DataGrids.Trial\n'), text.slice(0, 400)); console.log('search_wpf_docs / get_wpf_doc'); r = await call('search_wpf_docs', { control: 'XamDataGrid', limit: 5 }); @@ -143,6 +222,12 @@ async function main(): Promise { } r = await call('search_wpf_docs', { query: 'column series', limit: 3 }); check('docs-common topics resolved ({CategoryChartName} → real name)', !r.isError && /XamCategoryChart|XamDataChart/.test(textOf(r)), textOf(r).slice(0, 300)); + if (IS_DOTNET) { + // C# only: the TS server strips hyphens but not spaces from control names. + r = await call('search_wpf_docs', { control: 'ThemeManager', limit: 50 }); + text = textOf(r); + check('control "ThemeManager" matches topics tagged "IG Theme Manager"', !r.isError && text.includes('IG Theme Manager'), text.slice(0, 300)); + } r = await call('get_wpf_doc', { topic: 'no-such-topic-slug' }); check('unknown slug → isError', r.isError === true); @@ -154,12 +239,45 @@ async function main(): Promise { text = textOf(r); const path = /`(Themes\/[^`]+\.xaml)`/.exec(text)?.[1]; check('component+theme filter yields a resource path', !r.isError && !!path, text.slice(0, 300)); + if (IS_DOTNET) { + // The IG theme's class is IgTheme; `new IGTheme()` does not compile (C# server only — src/ is not updated). + r = await call('setup_wpf_theme', { component: 'DataChart', theme: 'IG' }); + text = textOf(r); + check('IG apply block uses the IgTheme class', !r.isError && text.includes('new Infragistics.Themes.IgTheme()') && !text.includes('IGTheme()'), text.slice(0, 600)); + } console.log('get_wpf_theme_resource'); if (path) { - r = await call('get_wpf_theme_resource', { path }); + // C# returns an overview for files over 24K (this one is ~155K) and the XAML by page; the frozen TS server cuts it off. + r = await call('get_wpf_theme_resource', IS_DOTNET ? { path, page: 1 } : { path }); check(`returns XAML for ${path}`, !r.isError && textOf(r).includes(' 1 && text.includes('## Contents') && text.length <= 25_000, text.slice(0, 300)); + let joined = ''; + for (let p = 1; p <= pageCount; p++) { + const pr = await call('get_wpf_theme_resource', { path, page: p }); + joined += /```xml\n([\s\S]*)\n```$/.exec(textOf(pr))?.[1] ?? '\u0000'; + } + const onDisk = readFileSync(join(dirname(TARGET.entry), 'data', 'theme-resources', path), 'utf8').replace(/^\uFEFF/, ''); + check(`its ${pageCount} pages concatenate to the exact file`, joined === onDisk, `${joined.length} vs ${onDisk.length} chars`); + // 36 matches here, so the result itself takes 2 pages: the root start tag is on the first, the end tag on the last. + r = await call('get_wpf_theme_resource', { path, find: 'Legend' }); + text = textOf(r); + const findPages = Number(/\(page 1 of (\d+)\)/.exec(text)?.[1] ?? 1); + let found = /```xml\n([\s\S]*)\n```$/.exec(text)?.[1] ?? ''; + for (let p = 2; p <= findPages; p++) + found += /```xml\n([\s\S]*)\n```$/.exec(textOf(await call('get_wpf_theme_resource', { path, find: 'Legend', page: p })))?.[1] ?? ''; + check('find returns matching entries in full, inside the root element', + !r.isError && /entr(y|ies) matching "Legend"/.test(text) && found.includes(''), + text.slice(0, 300)); + r = await call('get_wpf_theme_resource', { path, page: pageCount + 1 }); + check('a page past the end is a clear error', r.isError === true && textOf(r).includes('out of range')); + } r = await call('get_wpf_theme_resource', { path: '../../package.json' }); check('path traversal is rejected', r.isError === true); @@ -169,10 +287,51 @@ async function main(): Promise { check('MetroDark palette has color keys', !r.isError && /Color_\d+|Brush\d+/.test(text), text.slice(0, 300)); r = await call('get_wpf_theme_palette', {}); check('omitting theme returns the chooser', !r.isError && textOf(r).includes('MetroDark')); + if (IS_DOTNET) { + // Overriding palette keys does not recolor a theme (verified); the tool returns verified recolor steps + // instead, with class names read from each theme's own files (C# server only — src/ is not updated). + r = await call('get_wpf_theme_palette', { theme: 'RoyalDark' }); + text = textOf(r); + check('no palette-override skeleton', !r.isError && !text.includes('Override skeleton') && text.includes('does not recolor the theme'), text.slice(-600)); + check('RoyalDark steps name its compiled grid and DockManager classes', + text.includes('igThemes:DataPresenterRoyalDark.CellValuePresenter') && text.includes('DockManagerRoyalDark_Brushes.xaml') && text.includes('Infragistics.Themes.RoyalDarkTheme')); + r = await call('get_wpf_theme_palette', { theme: 'IG' }); + text = textOf(r); + check('IG steps use IgTheme and DataPresenterIGTheme', + !r.isError && text.includes('Infragistics.Themes.IgTheme') && text.includes('igThemes:DataPresenterIGTheme.CellValuePresenter') && !text.includes('IGTheme()')); + } + + if (IS_DOTNET) { + console.log('stderr'); + check('no SDK info lines without --debug', !/^info:/m.test(stderr.join('')), stderr.join('').slice(0, 300)); + } } finally { await client.close(); } + if (IS_DOTNET) { + // C# only: a second server with --debug; parallel calls must each get a log entry. stderr is + // ignored, not piped: --debug turns on the SDK's info lines and an unread pipe would fill up. + console.log('--debug log'); + const logPath = join(tmpdir(), `wpf-mcp-smoke-${process.pid}.log`); + rmSync(logPath, { force: true }); + const env = Object.fromEntries(Object.entries(process.env).filter((e): e is [string, string] => e[1] !== undefined)); + const debugClient = new Client({ name: 'wpf-mcp-smoke-test', version: '0.0.0' }); + await debugClient.connect(new StdioClientTransport({ + command: TARGET.command, args: [...TARGET.args, '--debug'], env: { ...env, WPF_MCP_LOG: logPath }, stderr: 'ignore', + })); + try { + const calls = 20; + await Promise.all(Array.from({ length: calls }, (_, i) => + debugClient.callTool({ name: 'search_wpf_api', arguments: { query: `filter ${i}`, limit: 1 } }))); + const entries = existsSync(logPath) ? readFileSync(logPath, 'utf8').match(/^\[[^\]]+\] search_wpf_api \(\d+ms\)$/gm)?.length ?? 0 : 0; + check(`${calls} parallel calls → ${calls} log entries`, entries === calls, `${entries} entries in ${logPath}`); + } finally { + await debugClient.close(); + rmSync(logPath, { force: true }); + } + } + const ms = Math.round(performance.now() - start); if (failures > 0) { console.error(`\n${failures} check(s) failed (${ms}ms)`); diff --git a/scripts/type-extractor/Program.cs b/scripts/type-extractor/Program.cs index f6a6207..fc995a3 100644 --- a/scripts/type-extractor/Program.cs +++ b/scripts/type-extractor/Program.cs @@ -8,8 +8,13 @@ * expected by scripts/build-api.ts loadTypeInfo(). * * Output format (flat object): - * "TypeShortName.__baseType" : "BaseTypeShortName" (only for Infragistics bases) - * "TypeShortName.PropName" : { typeName, isNullable, isEnum, enumValues? } + * "__baseTypes" : { "Assembly|Namespace.Type" : "BaseAssembly|BaseNamespace.BaseType" } + * (only for Infragistics bases; a generic base as its definition, "…ResourceSet`1") + * "Assembly|Namespace.Type.PropName" : { typeName, isNullable, isEnum, enumValues? } (instance and static) + * "Assembly|Namespace.Type.FieldName" : { typeName, isNullable, isEnum, isField: true } (static ResourceKey/RoutedCommand fields) + * "__xaml" : { "Assembly|Namespace.Type" (nested: "…|Namespace.Outer.Inner") : { xmlns, prefix, ns, xamlName? } } + * "__nonPublicTypes" : [ "Assembly|Namespace.Type", … ] (internal / private nested types, to leave out) + * "__nonPublicMembers" : [ "Assembly|Namespace.Type.Member", … ] (internal / private members, to leave out) */ using System; @@ -123,36 +128,226 @@ // Use a plain Dictionary — JsonSerializer serialises this cleanly var output = new SortedDictionary(StringComparer.Ordinal); +var baseTypes = new SortedDictionary(StringComparer.Ordinal); +var nonPublicMembers = new SortedSet(StringComparer.Ordinal); foreach (var asm in loaded) { foreach (var t in SafeGetTypes(asm)) { - if (!IsInfragisticsPublic(t)) continue; + var topLevel = IsInfragisticsPublic(t); + // IsVisible walks IsNested, which throws for types of the .NET Framework-only Exchange connector DLL. + bool nestedPublic; + try { nestedPublic = !topLevel && t.IsVisible && t.FullName!.StartsWith("Infragistics.", StringComparison.Ordinal); } + catch { nestedPublic = false; } + if (!topLevel && !nestedPublic) continue; + + // Member entries are keyed by assembly + full name like the maps below (nested types in the XML docs' form, + // "…|Namespace.Outer.Inner"): with a short-name key, same-named types overwrote each other's entries + // (the XGrid FilterOperand's Icon, a String, replaced the XamGrid one's DataTemplate). + var typeKey = $"{asm.GetName().Name}|{t.FullName!.Replace('+', '.')}"; + + // ── Members no app can use: internal, private, private protected ───── + // The XML docs document those too (ValueEditor.InternalIsInitialized, the XamDockManager.FlyoutOpening event). + // A name is listed only when no member of that name declared here is public or protected, so an overload set + // with one usable method stays. Explicit interface implementations ("IList.Add") are left to build-api's '#' rule. + // Nested public types too (DataPresenterBase.SelectedItemHolder.InternalClearSelection); the rest is top-level only. + try { + var usable = new Dictionary(StringComparer.Ordinal); + void Note(string name, bool ok) { + if (!name.Contains('.') && !name.StartsWith('<')) usable[name] = usable.GetValueOrDefault(name) || ok; + } + const BindingFlags declared = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | + BindingFlags.Static | BindingFlags.DeclaredOnly; + foreach (var p in t.GetProperties(declared)) Note(p.Name, IsUsable(p.GetMethod) || IsUsable(p.SetMethod)); + foreach (var e in t.GetEvents(declared)) Note(e.Name, IsUsable(e.AddMethod)); + foreach (var m in t.GetMethods(declared)) if (!m.IsSpecialName) Note(m.Name, IsUsable(m)); + foreach (var (name, ok) in usable) if (!ok) nonPublicMembers.Add($"{typeKey}.{name}"); + } catch { /* skip inaccessible type: its members stay */ } - var shortName = t.Name; + if (!topLevel) continue; // ── Base type (only emit when the base is also an Infragistics type) ── + // A generic base such as DataPresenterResourceSet has a FullName with type arguments; + // its definition is the known type, and Name ("DataPresenterResourceSet`1") matches build-api's key. + // Keyed by assembly + full name: 72 short names are shared by types with different bases + // (the chart Axis and the Excel Axis), and a short-name key let one overwrite the other. try { var bt = t.BaseType; - if (bt?.FullName != null && knownTypes.ContainsKey(bt.FullName)) - output[$"{shortName}.__baseType"] = bt.Name; + var btDef = bt is { IsGenericType: true } ? bt.GetGenericTypeDefinition() : bt; + if (btDef?.FullName != null && knownTypes.ContainsKey(btDef.FullName)) + baseTypes[typeKey] = $"{btDef.Assembly.GetName().Name}|{btDef.FullName}"; } catch { /* skip */ } // ── Properties (DeclaredOnly so we don't duplicate inherited ones) ──── + // Static ones too: theme classes expose their styles as static properties (DataPresenterResourceSet`1.CellValuePresenter). try { var props = t.GetProperties( - BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly); foreach (var prop in props) { try { var entry = BuildPropertyEntry(prop.PropertyType); - output[$"{shortName}.{prop.Name}"] = entry; + output[$"{typeKey}.{prop.Name}"] = entry; } catch { /* skip unparseable property */ } } } catch { /* skip inaccessible type */ } + + // ── Static key and command fields, used in XAML via {x:Static} ──────── + // (style/brush keys such as DockManagerBrushKeys.*, commands such as DataPresenterCommands.*). + // DependencyProperty and RoutedEvent fields are left out: their property/event is documented already. + if (t.IsEnum) continue; + try { + foreach (var field in t.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)) { + if (!typeof(System.Windows.ResourceKey).IsAssignableFrom(field.FieldType) && + !typeof(System.Windows.Input.RoutedCommand).IsAssignableFrom(field.FieldType)) continue; + var entry = BuildPropertyEntry(field.FieldType); + entry["isField"] = true; + output[$"{typeKey}.{field.Name}"] = entry; + } + } catch { /* skip inaccessible type */ } } } -Console.Error.WriteLine($"[TypeExtractor] phase 2: {output.Count} entries"); +output["__baseTypes"] = baseTypes; +output["__nonPublicMembers"] = nonPublicMembers; +Console.Error.WriteLine($"[TypeExtractor] phase 2: {output.Count} entries, {baseTypes.Count} base types, {nonPublicMembers.Count} non-public members"); + +// ── Non-public types ───────────────────────────────────────────────────────── +// The XML docs also document internal and private nested classes (ActivePaneManager, XamFunnelController, …), +// which no app can use. Only types reflection positively sees as non-public are listed — a type that can't be +// loaded (e.g. a .NET Framework-only DLL) is not, so a load failure can never hide public API. +var nonPublicTypes = new SortedSet(StringComparer.Ordinal); +foreach (var asm in loaded) { + Type?[] all; + try { all = asm.GetTypes(); } catch (ReflectionTypeLoadException ex) { all = ex.Types; } catch { continue; } + foreach (var t in all) { + if (t?.FullName == null || t.IsVisible || !t.FullName.StartsWith("Infragistics.", StringComparison.Ordinal)) continue; + nonPublicTypes.Add($"{asm.GetName().Name}|{t.FullName.Replace('+', '.')}"); + } +} +output["__nonPublicTypes"] = nonPublicTypes; +Console.Error.WriteLine($"[TypeExtractor] non-public types: {nonPublicTypes.Count}"); + +// ── Phase 3: the XAML namespace of every public type ───────────────────────── +// Resolved with System.Xaml's XamlSchemaContext — the XmlnsDefinition rules the WPF markup compiler applies — so +// a type only gets an xmlns that resolves to exactly that type, not a same-named one in another namespace or DLL. +// Several xmlns can map one CLR namespace; the most specific wins (http://infragistics.com/Themes over the +// catch-all http://schemas.infragistics.com/xaml/wpf). Types no Infragistics xmlns reaches get clr-namespace syntax. + +var xmlnsDefs = new List<(string Xmlns, string Ns, string Asm)>(); +var officialPrefixes = new Dictionary(StringComparer.Ordinal); +foreach (var asm in loaded) { + IList attrs; + try { attrs = asm.GetCustomAttributesData(); } catch { continue; } + foreach (var a in attrs) { + if (a.AttributeType.FullName == "System.Windows.Markup.XmlnsDefinitionAttribute") { + // AssemblyName lets one assembly map an xmlns onto types in another. + var target = a.NamedArguments.FirstOrDefault(n => n.MemberName == "AssemblyName").TypedValue.Value as string + ?? asm.GetName().Name!; + xmlnsDefs.Add(((string)a.ConstructorArguments[0].Value!, (string)a.ConstructorArguments[1].Value!, target)); + } else if (a.AttributeType.FullName == "System.Windows.Markup.XmlnsPrefixAttribute") { + officialPrefixes[(string)a.ConstructorArguments[0].Value!] = (string)a.ConstructorArguments[1].Value!; + } + } +} +var namespacesPerXmlns = xmlnsDefs.GroupBy(d => d.Xmlns).ToDictionary(g => g.Key, g => g.Select(d => d.Ns).Distinct().Count()); +// A DLL whose Infragistics references aren't in the set (a .NET Framework-only package referencing InfragisticsWPF) +// makes XamlSchemaContext throw for every xmlns lookup, so it is left out; its types fall back to clr-namespace. +var xamlAssemblies = loaded.Where(a => a.GetReferencedAssemblies() + .Where(r => r.Name!.StartsWith("Infragistics", StringComparison.OrdinalIgnoreCase)) + .All(r => dllByFilename.ContainsKey(r.Name + ".dll"))).ToList(); +foreach (var a in loaded.Except(xamlAssemblies)) + Console.Error.WriteLine($"[TypeExtractor] phase 3: {a.GetName().Name} left out of XAML resolution (references an Infragistics assembly not in the set)"); +var xamlContext = new System.Xaml.XamlSchemaContext(xamlAssemblies); + +bool ResolvesTo(string xmlns, string xamlName, Type t) { + try { return xamlContext.GetXamlType(new System.Xaml.Schema.XamlTypeName(xmlns, xamlName))?.UnderlyingType == t; } + catch { return false; } +} + +// XamlSchemaContext ignores internal types and settles same-named public ones by assembly order, but the WPF markup +// compiler does neither: XamDataTree's public NodesManager under http://schemas.infragistics.com/xaml fails with +// MC3064 because XamDiagram has an internal NodesManager under the same xmlns, and the RichTextEditor/SyntaxEditor +// twins depend on which package the app references. So an xmlns is only used if no other top-level type of that name, +// public or not, lives in a namespace it covers; otherwise the type gets clr-namespace syntax, which names its assembly. +var coveredBy = xmlnsDefs.GroupBy(d => d.Xmlns).ToDictionary( + g => g.Key, g => g.Select(d => (Asm: d.Asm.ToLowerInvariant(), d.Ns)).ToHashSet()); +// Same assemblies as the resolution above: a type of the left-out .NET Framework-only DLL can't even report IsNested. +var topLevelByName = xamlAssemblies + .SelectMany(a => { try { return a.GetTypes(); } catch (ReflectionTypeLoadException ex) { return ex.Types.OfType().ToArray(); } catch { return []; } }) + .Select(x => { try { return x.IsNested || x.FullName?.StartsWith("Infragistics.", StringComparison.Ordinal) != true ? null : x; } catch { return null; } }) + .OfType() + .GroupBy(x => x.Name).ToDictionary(g => g.Key, g => g.Select(x => (Asm: x.Assembly.GetName().Name!.ToLowerInvariant(), Ns: x.Namespace ?? "")).ToList()); +bool HasRival(string xmlns, string name, string asmName, string ns) => + topLevelByName.TryGetValue(name, out var same) && + same.Any(x => !(x.Asm == asmName.ToLowerInvariant() && x.Ns == ns) && coveredBy[xmlns].Contains(x)); + +var xamlTypes = new List<(string Key, string Ns, string Asm, string XamlName, string Xmlns, bool IsClr)>(); +var unresolved = 0; +foreach (var asm in xamlAssemblies) { + var asmName = asm.GetName().Name!; + foreach (var t in SafeGetTypes(asm)) { + if (!t.IsVisible || t.ContainsGenericParameters || t.FullName == null || + !t.FullName.StartsWith("Infragistics.", StringComparison.Ordinal)) continue; + var ns = t.Namespace ?? ""; + // Nested types are named Outer+Inner in XAML; the XML docs call them Namespace.Outer.Inner. + var xamlName = t.Name; + for (var outer = t.DeclaringType; outer != null; outer = outer.DeclaringType) xamlName = $"{outer.Name}+{xamlName}"; + + var xmlns = xmlnsDefs + .Where(d => d.Ns == ns && string.Equals(d.Asm, asmName, StringComparison.OrdinalIgnoreCase)) + .Select(d => d.Xmlns).Distinct() + .OrderBy(x => namespacesPerXmlns[x]).ThenBy(x => x, StringComparer.Ordinal) + .FirstOrDefault(x => ResolvesTo(x, xamlName, t) && !HasRival(x, xamlName.Split('+')[0], asmName, ns)); + var isClr = xmlns == null; + xmlns ??= $"clr-namespace:{ns};assembly={asmName}"; + if (isClr && !ResolvesTo(xmlns, xamlName, t)) { unresolved++; continue; } + // Keyed by assembly too: the same Namespace.Type can exist in two DLLs (Caret in RichTextEditor and SyntaxEditor). + xamlTypes.Add(($"{asmName}|{ns}.{xamlName.Replace('+', '.')}", ns, asmName, xamlName, xmlns, isClr)); + } +} + +// Prefixes, one per xmlns: the DLLs' own XmlnsPrefix (igDP, igThemes, …), else "ig" + the xmlns' last path segment. +// A clr-namespace gets "ig" + the shortest tail of its namespace that is unique and doesn't clash with those +// (igCalculationStrategies); when one namespace spans several DLLs, the DLL's last name segment is added +// (igControlsEditorsXamSyntaxEditor), and a counter is the last resort. +string Cap(string s) => s.Length == 0 ? s : char.ToUpperInvariant(s[0]) + s[1..]; +string XmlnsPrefix(string xmlns) => officialPrefixes.TryGetValue(xmlns, out var p) ? p : "ig" + Cap(xmlns.TrimEnd('/').Split('/').Last()); +var reserved = xamlTypes.Where(x => !x.IsClr).Select(x => XmlnsPrefix(x.Xmlns)).ToHashSet(StringComparer.Ordinal); +var clr = xamlTypes.Where(x => x.IsClr).GroupBy(x => x.Xmlns).ToDictionary(g => g.Key, g => ( + Segments: g.First().Ns.Split('.').Skip(g.First().Ns.StartsWith("Infragistics", StringComparison.Ordinal) ? 1 : 0).Select(Cap).ToArray(), + AsmSegment: Cap(g.First().Asm.Split('.').Last()))); +var depth = clr.Keys.ToDictionary(k => k, _ => 1); +var withAsm = new HashSet(StringComparer.Ordinal); +string ClrPrefix(string xmlns) { + var (segments, asmSegment) = clr[xmlns]; + var tail = string.Concat(segments.TakeLast(depth[xmlns])); + return "ig" + (tail.Length > 0 ? tail : "Infragistics") + (withAsm.Contains(xmlns) ? asmSegment : ""); +} +for (var changed = true; changed;) { + changed = false; + foreach (var group in clr.Keys.GroupBy(ClrPrefix).Where(g => g.Count() > 1 || reserved.Contains(g.Key)).ToList()) + foreach (var xmlns in group) { + if (depth[xmlns] < clr[xmlns].Segments.Length) depth[xmlns]++; + else if (!withAsm.Add(xmlns)) continue; + changed = true; + } +} +var clrPrefixes = clr.Keys.GroupBy(ClrPrefix).SelectMany(g => g.OrderBy(x => x, StringComparer.Ordinal) + .Select((xmlns, i) => (xmlns, prefix: g.Count() == 1 && !reserved.Contains(g.Key) ? g.Key : $"{g.Key}{i + 1}"))) + .ToDictionary(x => x.xmlns, x => x.prefix); + +var xamlOut = new SortedDictionary(StringComparer.Ordinal); +foreach (var x in xamlTypes) { + var info = new Dictionary { + ["xmlns"] = x.Xmlns, + ["prefix"] = x.IsClr ? clrPrefixes[x.Xmlns] : XmlnsPrefix(x.Xmlns), + ["ns"] = x.Ns, + }; + if (x.XamlName.Contains('+')) info["xamlName"] = x.XamlName; + xamlOut[x.Key] = info; +} +output["__xaml"] = xamlOut; +Console.Error.WriteLine($"[TypeExtractor] phase 3: {xamlOut.Count} XAML namespaces ({xamlTypes.Count(x => x.IsClr)} clr-namespace, {unresolved} unresolvable)"); // ── Write JSON ──────────────────────────────────────────────────────────────── @@ -214,6 +409,9 @@ static bool IsInfragisticsDll(string filename) { !filename.Contains(".resources.", StringComparison.OrdinalIgnoreCase); } +// Callable from an app: public, or protected (from a subclass). Not internal, private or private protected. +static bool IsUsable(MethodBase? m) => m != null && (m.IsPublic || m.IsFamily || m.IsFamilyOrAssembly); + static bool IsInfragisticsPublic(Type t) => t.IsPublic && t.FullName != null && diff --git a/scripts/validate-package.ts b/scripts/validate-package.ts index 88fbfed..caa1d6b 100644 --- a/scripts/validate-package.ts +++ b/scripts/validate-package.ts @@ -31,7 +31,7 @@ const TOOL_DIR = 'tools/net10.0/any/'; const MIN_COMPONENTS = 150; const MIN_SEARCH_ENTRIES = 5000; const MIN_SEARCH_BYTES = 2 * 1024 * 1024; -const MIN_API_FILES = 6000; +const MIN_API_FILES = 5000; // ~5,340 public types on 26.1.21 (internal classes are not indexed) const MIN_DOC_TOPICS = 2000; const MIN_NEWER_THEMES = 5; const MIN_LEGACY_FOLDERS = 20; @@ -148,6 +148,9 @@ if (components) { errors.push(`namespaces.json has ${components.length} components < ${MIN_COMPONENTS}`); } else if (components.some(c => !c.component || !c.xamlNamespace || !c.nugetPackage)) { errors.push('namespaces.json has entries missing component/xamlNamespace/nugetPackage'); + } else if (components.some(c => c.nugetPackage === c.nugetPackage.toLowerCase())) { + // Lowercase means the ID came from the nuget/packages folder name, not the .nuspec . + errors.push('namespaces.json has lowercase nugetPackage IDs (e.g. "infragistics.wpf.datagrids.trial") — build-api.ts must take the canonical casing from the .nuspec'); } else { ok('data', String(components.length), 'components in namespaces.json'); } diff --git a/server/Data/Models.cs b/server/Data/Models.cs index 2489e5e..016d93f 100644 --- a/server/Data/Models.cs +++ b/server/Data/Models.cs @@ -21,7 +21,8 @@ public sealed record ApiMemberEntry( bool? IsNullable, bool? IsEnum, string[]? EnumValues, - string? DeclaredOn); + string? DeclaredOn, + bool? IsField = null); // a static ResourceKey/RoutedCommand field, listed with the properties public sealed record AlternateType(string FullName, string NugetPackage, int MemberCount); @@ -38,7 +39,8 @@ public sealed record ApiEntry( AlternateType[]? Alternates, ApiMemberEntry[] Properties, ApiMemberEntry[] Events, - ApiMemberEntry[] Methods); + ApiMemberEntry[] Methods, + string? XamlName = null); // nested types only: "Outer+Inner" /// Compact entry in search-index.json — single-letter keys keep the ~5MB index small. public sealed record SearchIndexEntry( diff --git a/server/Program.cs b/server/Program.cs index c1f6f04..b4be1ef 100644 --- a/server/Program.cs +++ b/server/Program.cs @@ -11,7 +11,8 @@ .Split('+')[0]; var store = new DataStore(Path.Combine(AppContext.BaseDirectory, "data")); -var log = new ToolLog(args.Contains("--debug")); +var debug = args.Contains("--debug"); +var log = new ToolLog(debug); // Content root = the tool's own folder, not the MCP client's working directory, so an // unrelated (or malformed) appsettings.json in the user's workspace is never loaded. @@ -22,6 +23,9 @@ }); // stdout is the MCP transport — every log line must go to stderr. builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); +// MCP clients surface the server's stderr in their logs; the SDK's per-request info lines are +// only worth it when debugging. The "ready" line below is Console.Error, so it always shows. +builder.Logging.SetMinimumLevel(debug ? LogLevel.Information : LogLevel.Warning); builder.Services.AddSingleton(store); builder.Services.AddSingleton(log); builder.Services diff --git a/server/README.md b/server/README.md index c3bdf34..fd7102c 100644 --- a/server/README.md +++ b/server/README.md @@ -2,7 +2,7 @@ -MCP server for **Infragistics Ultimate UI for WPF** — component registry, XAML namespace lookup, full API reference with property types and enum values, keyword search across 6,900+ types, 2,600+ documentation topics, and named-theme setup with palette re-coloring. Everything an AI coding agent needs to write correct Infragistics XAML instead of guessing. +MCP server for **Infragistics Ultimate UI for WPF** — component registry, XAML namespace lookup, full API reference with property types and enum values, keyword search across 5,300+ public types, 2,600+ documentation topics, and named-theme setup with verified recolor steps. Everything an AI coding agent needs to write correct Infragistics XAML instead of guessing. Packaged as a `dotnet tool`. All Infragistics data ships inside the package; nothing is downloaded at runtime, and every tool is read-only. @@ -68,12 +68,20 @@ dnx Infragistics.Wpf.Mcp --prerelease --yes } ``` -**Claude Code** — from a terminal: +**Claude Code** (CLI or VS Code extension) — add `.mcp.json` to your project root: -```bash -claude mcp add infragistics-wpf -- wpf-mcp +```json +{ + "mcpServers": { + "infragistics-wpf": { + "command": "wpf-mcp" + } + } +} ``` +Approve the server when Claude Code asks, then type `/mcp` in the chat to check that `infragistics-wpf` is connected. If the `claude` command is on your `PATH` (the VS Code extension doesn't add it), you can run `claude mcp add infragistics-wpf -- wpf-mcp` instead. + To use `dnx` instead of a global install, set `"command": "dnx"` and `"args": ["Infragistics.Wpf.Mcp@0.1.0-beta.0", "--yes"]` in any of the above (`claude mcp add infragistics-wpf -- dnx Infragistics.Wpf.Mcp@0.1.0-beta.0 --yes`). ## Tools @@ -85,8 +93,8 @@ To use `dnx` instead of a global install, set `"command": "dnx"` and `"args": [" | `search_wpf_api` | Search across all types by keyword — matches type names, summaries, and member names. Use when you don't know the exact type name. | | `get_wpf_api_reference` | Full API reference for any type: properties with types and enum values, events, methods, and inherited members grouped by base class. Large types are compacted to stay under ~25K characters (events and methods first, then inherited members, are listed by name only); the optional `member` filter (case-insensitive name substring, e.g. `"YAxis"`) returns full details for just the members you need. | | `setup_wpf_theme` | List available named themes and the raw XAML files backing them, covering both theming mechanisms (legacy embedded-BAML `Theme="..."` and newer `ThemeManager`). Returns ready-to-paste apply steps. | -| `get_wpf_theme_resource` | Retrieve the full raw XAML of one theme/style resource-dictionary file (real `Style`/`ControlTemplate`/brush definitions) to copy and override. | -| `get_wpf_theme_palette` | Return a newer-family (ThemeManager) theme's centralized color/brush palette — exact resource keys, values, and a ready-to-merge override skeleton — for re-coloring a theme without creating a new one. | +| `get_wpf_theme_resource` | Retrieve the full raw XAML of one theme/style resource-dictionary file (real `Style`/`ControlTemplate`/brush definitions) to copy and override. Large files come in pages that concatenate to the exact file; `find` returns single styles, templates or brushes in full. | +| `get_wpf_theme_palette` | Return a newer-family (ThemeManager) theme's color/brush palette — exact resource keys and values — plus verified steps to recolor that theme (control-level overrides or an extended theme) without creating a new one. | | `search_wpf_docs` | Search how-to documentation topics by keyword and/or control name — layouts, styling, data binding, filtering/sorting/grouping, exporting, performance, known issues, etc. | | `get_wpf_doc` | Full text (including XAML samples) of one documentation topic by slug. | @@ -97,7 +105,7 @@ All tools are annotated `readOnlyHint: true`, `openWorldHint: false` — none of - **`infragistics.wpf.mcp is not found in NuGet feeds`** — the command is missing `--prerelease` (or an exact `@0.1.0-beta.0` version); see [Install](#install). - **`Settings file 'DotnetToolSettings.xml' was not found in the package`** — the active .NET SDK is older than 10 (check `dotnet --version`; a `global.json` in the current folder can pin an older one). Install the .NET 10 SDK. - **Which Infragistics version is the data from?** The server prints it to stderr on startup: `Infragistics WPF MCP server 0.1.0-beta.0 ready (data: Infragistics 26.1.21, built )`. -- **Log every tool call** — add `"args": ["--debug"]` to the client configuration. Each call's input, output and timing goes to `wpf-mcp.log` in your system temp folder (override with the `WPF_MCP_LOG` environment variable); the exact path is printed to stderr on startup. +- **Log every tool call** — add `"args": ["--debug"]` to the client configuration. Each call's input, output and timing goes to `wpf-mcp.log` in your system temp folder (override with the `WPF_MCP_LOG` environment variable); the exact path is printed to stderr on startup. `--debug` also turns on the MCP SDK's per-request logging on stderr, which is otherwise limited to warnings and errors. - **`wpf-mcp` is not found** after `dotnet tool install -g` — the global tools folder (`%USERPROFILE%\.dotnet\tools` on Windows, `~/.dotnet/tools` elsewhere) is not on the `PATH` of the process that launches the client. Restart the client, or use the full path as `command`. - **An answer looks wrong or a control seems missing** — this is usually stale data in Infragistics' own NuGet package (summaries, base types) rather than a bug in this server. Cross-check against the pinned version above before assuming the MCP itself is at fault. diff --git a/server/ServerInstructions.cs b/server/ServerInstructions.cs index c699efb..0df0def 100644 --- a/server/ServerInstructions.cs +++ b/server/ServerInstructions.cs @@ -14,10 +14,10 @@ public static class ServerInstructions 3. Whenever the task needs HOW-TO guidance beyond "what members exist" — layouts/nesting, styling/theming, data binding, editing/validation, filtering/sorting/grouping/summaries, exporting, performance, commands, known issues, etc. — call search_wpf_docs(query, control: component), passing the SAME component name from step 1/2 to scope the search. 4. get_wpf_doc(slug) on the most relevant result from step 3 to read the full XAML example or how-to text before writing any code or giving usage advice. 5. For new projects, get_wpf_project_scaffold(components) after step 1 for dotnet CLI + xmlns setup, then still run steps 2-4 per component before writing real XAML. - 6. Establish the visual theme EARLY — treat it as a default step for any new window/app, not an optional afterthought. Call setup_wpf_theme(component?, theme?) to pick a named theme and get the exact ready-to-paste apply steps (NuGet package + App.xaml.cs ThemeManager call for the newer family, or Theme="" for the legacy family) plus the resource file paths; then get_wpf_theme_resource(path) only if you need to copy/override a specific Style/ControlTemplate. When the goal is to RE-COLOR an existing theme (change its palette) rather than restyle one control, call get_wpf_theme_palette(theme) for that theme's centralized color/brush keys and a ready-to-merge override skeleton. + 6. Establish the visual theme EARLY — treat it as a default step for any new window/app, not an optional afterthought. Call setup_wpf_theme(component?, theme?) to pick a named theme and get the exact ready-to-paste apply steps (NuGet package + App.xaml.cs ThemeManager call for the newer family, or Theme="" for the legacy family) plus the resource file paths; then get_wpf_theme_resource(path) only if you need to copy/override a specific Style/ControlTemplate (find: "" returns just that entry; large files come in pages). When the goal is to RE-COLOR an existing theme (brand colors, a different accent) rather than restyle one control, call get_wpf_theme_palette(theme) for that theme's colors plus the verified recolor steps — overriding the palette keys themselves does NOT recolor a theme. ALWAYS call list_wpf_components/search_wpf_api before writing any XAML to get the correct xmlns namespace URI; wrong values cause immediate compile errors. - ALWAYS establish a theme via setup_wpf_theme (then get_wpf_theme_palette to recolor if needed) BEFORE writing any Style, Setter, Brush, or color attribute on an Infragistics control. Do NOT hand-author a color scheme (dark, neon, corporate, etc.) to approximate a look — a named theme or palette override is the correct, lower-maintenance path. + ALWAYS establish a theme via setup_wpf_theme (then get_wpf_theme_palette to recolor if needed) BEFORE writing any Style, Setter, Brush, or color attribute on an Infragistics control. Do NOT hand-author a color scheme (dark, neon, corporate, etc.) to approximate a look — a named theme plus the verified recolor steps from get_wpf_theme_palette is the correct, lower-maintenance path. Never guess property names, child-element nesting, or other usage details (styling, data binding, performance, etc.) — verify through this chain rather than assuming from a similar control or from naming conventions. """; diff --git a/server/Tools/ComponentTools.cs b/server/Tools/ComponentTools.cs index 31dae5c..e36f7f1 100644 --- a/server/Tools/ComponentTools.cs +++ b/server/Tools/ComponentTools.cs @@ -25,6 +25,11 @@ public CallToolResult ListComponents( { var timer = Stopwatch.StartNew(); var input = new { filter }; + if (ToolInput.CheckLength("filter", filter, max: 128) is { } invalid) + { + log.Write("list_wpf_components", input, invalid, timer); + return ToolResult.Error(invalid); + } var components = store.Components; // Same relevance ladder as handlers.ts: name match always outranks a description or @@ -68,7 +73,7 @@ static double MatchScore(ComponentEntry c, string needle) { var line = new List { - $"## {c.Component}", + $"### {c.Component}", $"- **xmlns:** `xmlns:{c.DefaultPrefix}=\"{c.XamlNamespace}\"`", $"- **NuGet:** `{c.NugetPackage}`", $"- **Assembly:** `{c.Assembly}`", @@ -79,7 +84,7 @@ static double MatchScore(ComponentEntry c, string needle) return string.Join('\n', line); }); - var header = $"# Infragistics WPF Components ({matches.Length}{(string.IsNullOrEmpty(filter) ? " total" : $" matching \"{filter}\"")})"; + var header = $"## Infragistics WPF Components ({matches.Length}{(string.IsNullOrEmpty(filter) ? " total" : $" matching \"{filter}\"")})"; var text = string.Join("\n\n", new[] { header, "" }.Concat(lines)); log.Write("list_wpf_components", input, text, timer); @@ -97,6 +102,7 @@ private static string FormatMember(ApiMemberEntry m) if (m.IsEnum == true && m.EnumValues is { Length: > 0 }) line += $" — values: `{string.Join("` | `", m.EnumValues)}`"; } + if (m.IsField == true) line += " _(static field)_"; line += $": {(string.IsNullOrEmpty(m.Summary) ? "—" : m.Summary)}"; return line; } @@ -146,14 +152,14 @@ private static List RenderMemberSections((ApiMemberEntry m, string kind) if (direct.Length > 0) { output.Add(""); - output.Add($"## {label}"); + output.Add($"### {label}"); if (IsCompact(detail, kindKey, inherited: false)) output.Add(FormatMemberNames(direct)); else output.AddRange(direct.Select(FormatMember)); } foreach (var group in inherited) { output.Add(""); - output.Add($"## {label} (inherited from `{group.Key}`)"); + output.Add($"### {label} (inherited from `{group.Key}`)"); if (IsCompact(detail, kindKey, inherited: true)) output.Add(FormatMemberNames(group)); else output.AddRange(group.Select(FormatMember)); } @@ -165,7 +171,7 @@ private static List RenderMemberSections((ApiMemberEntry m, string kind) [Description(ToolDescriptions.GetWpfApiReference)] public CallToolResult GetApiReference( [Description(""" - Any Infragistics WPF type name — 7000+ types including controls (e.g. "XamDataGrid"), + Any public Infragistics WPF type name — 5,300+ types including controls (e.g. "XamDataGrid"), supporting types (e.g. "FieldLayout", "SummaryDefinition"), enums, strategies, indicators, and more. Case-insensitive. To find a Xam* control name use list_wpf_components. To discover any other type name use search_wpf_api. """)] @@ -179,11 +185,16 @@ supporting types (e.g. "FieldLayout", "SummaryDefinition"), enums, strategies, i Examples: "YAxis" (every YAxis* member), "FieldLayout", "Changed". Use it to check specific members on a large type, or to get details of members a compacted response lists by name only. """)] - [MaxLength(64)] + [MaxLength(128)] string? member = null) { var timer = Stopwatch.StartNew(); var input = new { component, kind = kind.ToString(), member }; + if ((ToolInput.CheckLength("component", component, max: 128, min: 1) ?? ToolInput.CheckLength("member", member, max: 128)) is { } invalid) + { + log.Write("get_wpf_api_reference", input, invalid, timer); + return ToolResult.Error(invalid); + } var memberFilter = member?.Trim(); var doc = store.LoadApiDoc(component); @@ -211,16 +222,24 @@ or to get details of members a compacted response lists by name only. var summary = string.IsNullOrEmpty(doc.Summary) ? "_(no summary)_" : doc.Summary; var output = new List { - $"# {doc.Component}", + $"## {doc.Component}", "", $"**xmlns:** `xmlns:{doc.DefaultPrefix}=\"{doc.XamlNamespace}\"`", + }; + if (doc.XamlName is { } xamlName) + output.Add($"**XAML name:** `{doc.DefaultPrefix}:{xamlName}` (nested class)"); + // clr-namespace names the assembly, and only the .NET (8+) builds are called Infragistics.WPF.*. + if (doc.XamlNamespace.StartsWith("clr-namespace:", StringComparison.Ordinal) && doc.Assembly.StartsWith("Infragistics.WPF", StringComparison.Ordinal)) + output.Add($"_.NET Framework 4.x apps: use `assembly=InfragisticsWPF{doc.Assembly["Infragistics.WPF".Length..]}` (that build's assembly name)._"); + output.AddRange(new[] + { $"**NuGet:** `{doc.NugetPackage}`", $"**Assembly:** `{doc.Assembly}`", $"**Full type name:** `{doc.DotnetNamespace}.{doc.Component}`", "", - "## Summary", + "### Summary", summary, - }; + }); if (doc.Alternates is { Length: > 0 }) { @@ -234,7 +253,7 @@ or to get details of members a compacted response lists by name only. if (!string.IsNullOrEmpty(doc.Remarks)) { output.Add(""); - output.Add("## Remarks"); + output.Add("### Remarks"); output.Add(doc.Remarks); } @@ -324,6 +343,11 @@ Call list_wpf_components first to resolve exact names. { var timer = Stopwatch.StartNew(); var input = new { components, projectName, framework = framework.Wire() }; + if (ToolInput.CheckLength("projectName", projectName, max: 128, min: 1) is { } invalid) + { + log.Write("get_wpf_project_scaffold", input, invalid, timer); + return ToolResult.Error(invalid); + } if (!ProjectNameRegex().IsMatch(projectName)) { @@ -380,9 +404,9 @@ Call list_wpf_components first to resolve exact names. lines.Add(""); } - lines.Add($"# WPF Project Scaffold: `{projectName}`"); + lines.Add($"## WPF Project Scaffold: `{projectName}`"); lines.Add(""); - lines.Add("## 1. Create and configure the project"); + lines.Add("### 1. Create and configure the project"); lines.Add(""); lines.Add("```bash"); lines.Add($"dotnet new wpf -n {projectName} --framework {framework.Wire()}"); @@ -391,7 +415,7 @@ Call list_wpf_components first to resolve exact names. lines.Add("dotnet restore"); lines.Add("```"); lines.Add(""); - lines.Add("## 2. Add xmlns declarations to MainWindow.xaml"); + lines.Add("### 2. Add xmlns declarations to MainWindow.xaml"); lines.Add(""); lines.Add("```xml"); lines.Add(" $"- **{r.component}** → `{r.nugetPackage}`")); var text = string.Join('\n', lines); diff --git a/server/Tools/DocsTools.cs b/server/Tools/DocsTools.cs index 107b14a..a5159aa 100644 --- a/server/Tools/DocsTools.cs +++ b/server/Tools/DocsTools.cs @@ -37,10 +37,15 @@ At least one of `query` or `control` must be provided. { var timer = Stopwatch.StartNew(); var input = new { query, control, limit }; + if ((ToolInput.CheckLength("query", query, max: 128) ?? ToolInput.CheckLength("control", control, max: 64)) is { } invalid) + { + log.Write("search_wpf_docs", input, invalid, timer); + return ToolResult.Error(invalid); + } var tokens = SearchTools.Tokenize(query); var controlFilter = control?.ToLowerInvariant().Trim(); if (string.IsNullOrEmpty(controlFilter)) controlFilter = null; - limit = Math.Min(limit, 50); + limit = Math.Clamp(limit, 1, 50); if (tokens.Length == 0 && controlFilter is null) { @@ -54,12 +59,13 @@ At least one of `query` or `control` must be provided. foreach (var entry in store.DocIndex) { - // Hard pre-filter on control name: bidirectional substring match with hyphens - // stripped, so "FinancialChart" ~ "XamFinancialChart" and "financial-chart" both hit. + // Hard pre-filter on control name: bidirectional substring match with hyphens and spaces + // stripped, so "FinancialChart" ~ "XamFinancialChart" and "financial-chart" both hit, and + // "ThemeManager" ~ "IG Theme Manager". if (controlFilter is not null && !entry.ControlNames.Any(c => { - var cNorm = c.ToLowerInvariant().Replace("-", ""); - var fNorm = controlFilter.Replace("-", ""); + var cNorm = c.ToLowerInvariant().Replace("-", "").Replace(" ", ""); + var fNorm = controlFilter.Replace("-", "").Replace(" ", ""); return cNorm.Contains(fNorm, StringComparison.Ordinal) || fNorm.Contains(cNorm, StringComparison.Ordinal); })) { @@ -119,7 +125,7 @@ At least one of `query` or `control` must be provided. var queryLabel = !string.IsNullOrEmpty(query) ? $"\"{query}\"" : "(browsing by control)"; var text = string.Join("\n\n", new[] { - $"# WPF Documentation Search: {queryLabel} ({limited.Length} of {totalFound} matches)", + $"## WPF Documentation Search: {queryLabel} ({limited.Length} of {totalFound} matches)", "", } .Concat(lines) @@ -152,6 +158,11 @@ public CallToolResult GetDoc( { var timer = Stopwatch.StartNew(); var input = new { topic }; + if (ToolInput.CheckLength("topic", topic, max: 160, min: 1) is { } invalid) + { + log.Write("get_wpf_doc", input, invalid, timer); + return ToolResult.Error(invalid); + } var doc = store.LoadDoc(topic); if (doc is null) @@ -173,7 +184,7 @@ public CallToolResult GetDoc( var output = new List { - $"# {(string.IsNullOrEmpty(doc.Title) ? doc.Slug : doc.Title)}", + $"## {(string.IsNullOrEmpty(doc.Title) ? doc.Slug : doc.Title)}", "", $"**Topic slug:** `{doc.Slug}`", $"**Source:** {(doc.Source == "wpf" ? "docs-wpf" : "docs-common (shared cross-platform topic)")}", @@ -184,7 +195,7 @@ public CallToolResult GetDoc( if (doc.XamlSnippets.Length > 0) { output.Add(""); - output.Add("## XAML Examples"); + output.Add("### XAML Examples"); var shown = doc.XamlSnippets.Take(MaxSnippetsShown).ToArray(); for (var i = 0; i < shown.Length; i++) { @@ -193,7 +204,7 @@ public CallToolResult GetDoc( ? $"{snippet[..MaxSnippetChars]}\n" : snippet; output.Add(""); - output.Add($"### Example {i + 1}"); + output.Add($"#### Example {i + 1}"); output.Add("```xml"); output.Add(text); output.Add("```"); @@ -215,7 +226,7 @@ public CallToolResult GetDoc( ? $"{doc.Body[..MaxBodyChars]}\n\n_(truncated, {doc.Body.Length} chars total)_" : doc.Body; output.Add(""); - output.Add("## Full Topic Text"); + output.Add("### Full Topic Text"); output.Add(""); output.Add(body); diff --git a/server/Tools/SearchTools.cs b/server/Tools/SearchTools.cs index a5905b8..be24c8e 100644 --- a/server/Tools/SearchTools.cs +++ b/server/Tools/SearchTools.cs @@ -38,8 +38,13 @@ public CallToolResult SearchApi( { var timer = Stopwatch.StartNew(); var input = new { query, limit }; + if (ToolInput.CheckLength("query", query, max: 128, min: 1) is { } invalid) + { + log.Write("search_wpf_api", input, invalid, timer); + return ToolResult.Error(invalid); + } var tokens = Tokenize(query); - limit = Math.Min(limit, 50); + limit = Math.Clamp(limit, 1, 50); var results = new List<(string typeName, string summary, string nugetPackage, string matchedOn, int score)>(); @@ -86,7 +91,7 @@ public CallToolResult SearchApi( var text = string.Join("\n\n", new[] { - $"# WPF API Search: \"{query}\" ({limited.Length} of {totalFound} matches)", + $"## WPF API Search: \"{query}\" ({limited.Length} of {totalFound} matches)", "", } .Concat(lines) diff --git a/server/Tools/ThemeTools.cs b/server/Tools/ThemeTools.cs index a834e87..0a1007b 100644 --- a/server/Tools/ThemeTools.cs +++ b/server/Tools/ThemeTools.cs @@ -14,7 +14,7 @@ public sealed partial class ThemeTools(DataStore store, ToolLog log) // ── setup_wpf_theme ────────────────────────────────────────────────────── private const string NewerMechanismLine = - "_Newer family (`Themes/`): apply via `Infragistics.Themes.ThemeManager.ApplicationTheme = new Theme();` in App.xaml.cs (requires the `Infragistics.WPF.Themes..Trial` NuGet package). Do NOT merge these files directly into `Application.Resources`._"; + "_Newer family (`Themes/`): apply via `Infragistics.Themes.ThemeManager.ApplicationTheme = new Theme();` in App.xaml.cs (the IG theme's class is `IgTheme`; requires the `Infragistics.WPF.Themes..Trial` NuGet package). Do NOT merge these files directly into `Application.Resources`._"; private const string LegacyMechanismLine = "_Legacy family (`DefaultStyles/`): the named theme is already embedded in the component's assembly — just set `Theme=\"\"` on the control. Use these files only to copy/override individual Styles/ControlTemplates._"; @@ -32,11 +32,16 @@ public sealed partial class ThemeTools(DataStore store, ToolLog log) "dotnet add package Infragistics.WPF.Themes..Trial", "```", "```csharp", - "// App.xaml.cs — set before the first window is created", + "// App.xaml.cs — set before the first window is created (the IG theme's class is IgTheme)", "Infragistics.Themes.ThemeManager.ApplicationTheme = new Infragistics.Themes.Theme();", "```", }); + // ThemeManager theme classes are "Theme" (MetroDarkTheme, RoyalDarkTheme, …) except IG, + // whose class is IgTheme: `new IGTheme()` fails with CS0234 (verified by compiling, 26.1.54). + private static string ThemeClassName(string theme) => + string.Equals(theme, "IG", StringComparison.OrdinalIgnoreCase) ? "IgTheme" : $"{theme}Theme"; + private static string NewerApplyBlock(string theme) => string.Join('\n', new[] { "", @@ -48,7 +53,7 @@ private static string NewerApplyBlock(string theme) => string.Join('\n', new[] "// App.xaml.cs — set before the first window is created", "protected override void OnStartup(StartupEventArgs e)", "{", - $" Infragistics.Themes.ThemeManager.ApplicationTheme = new Infragistics.Themes.{theme}Theme();", + $" Infragistics.Themes.ThemeManager.ApplicationTheme = new Infragistics.Themes.{ThemeClassName(theme)}();", " base.OnStartup(e);", "}", "```", @@ -224,9 +229,11 @@ componentFilter is null || // ── get_wpf_theme_resource ─────────────────────────────────────────────── - // Style files average ~60KB and can reach ~660KB; 24000 chars (~5-6k tokens) fits a - // full ControlTemplate while capping the largest blobs. + // Files up to this size come back whole, as they always have. Larger ones (436 of 822 on 26.1.21, up to 659K) + // used to be cut off here; now they come as a contents overview plus pages, or just the entries `find` matches. private const int MaxThemeFileChars = 24000; + // XAML per page; with the response's header this stays under the 25K get_wpf_api_reference keeps to as well. + private const int ThemePageChars = 23000; [McpServerTool(Name = "get_wpf_theme_resource", ReadOnly = true, Idempotent = true, OpenWorld = false)] [Description(ToolDescriptions.GetWpfThemeResource)] @@ -237,10 +244,23 @@ public CallToolResult GetThemeResource( Call setup_wpf_theme first — never guess this path. """)] [MinLength(1), MaxLength(200)] - string path) + string path, + [Description(""" + Optional: return only the file's top-level entries (Style, ControlTemplate, brush, …) whose x:Key, TargetType + or element name contains this text (case-insensitive), each in full, e.g. "CheckBox", "ButtonStyle", "Brush". + The result is wrapped in the file's own root element, so its xmlns declarations come along. + """)] + [MaxLength(64)] + string? find = null, + [Description(""" + Optional page number for files too large for one response (the response says how many pages there are). + Pages split between top-level elements and concatenate to the exact file. Also pages through large find results. + """)] + [Range(1, 1000)] + int? page = null) { var timer = Stopwatch.StartNew(); - var input = new { path }; + var input = new { path, find, page }; var content = store.LoadThemeResource(path); if (content is null) @@ -263,26 +283,86 @@ Call setup_wpf_theme first — never guess this path. var isNewerFamily = path.Replace('\\', '/').StartsWith("Themes/", StringComparison.Ordinal); var mechanismNote = isNewerFamily - ? "_Newer family: apply via `Infragistics.Themes.ThemeManager.ApplicationTheme = new Theme();` — do not merge this file directly into Application.Resources._" + ? "_Newer family: apply via `Infragistics.Themes.ThemeManager.ApplicationTheme = new Theme();` (the IG theme's class is `IgTheme`) — do not merge this file directly into Application.Resources._" : "_Legacy family: the named theme is already embedded in the component assembly — set `Theme=\"...\"` on the control. Use this file to copy/override specific styles only._"; - var shown = content.Length > MaxThemeFileChars - ? $"{content[..MaxThemeFileChars]}\n" - : content; + CallToolResult Respond(string text, bool isError = false) + { + log.Write("get_wpf_theme_resource", input, text, timer); + return isError ? ToolResult.Error(text) : ToolResult.Text(text); + } + string Xaml(string heading, string note, string xaml) => + string.Join('\n', new[] { heading, "", mechanismNote, "", note, "", "```xml", xaml, "```" }); + string? PageError(int count) => page is { } p && (p < 1 || p > count) + ? $"page {p} is out of range: \"{path}\"{(find is null ? "" : $" filtered by find \"{find.Trim()}\"")} has {count} page{(count == 1 ? "" : "s")} (page: 1{(count > 1 ? $" to {count}" : "")})." + : null; + + var parts = XamlPages.Parse(content); // null for a dictionary without child elements + var findText = find?.Trim(); - var text = string.Join('\n', new[] + // find: only the matching top-level entries, in full, inside the file's own root element. + if (!string.IsNullOrEmpty(findText)) { - $"# {path}", - "", - mechanismNote, - "", - "```xml", - shown, - "```", - }); + var matches = parts?.Entries.Where(e => e.Search.Contains(findText, StringComparison.OrdinalIgnoreCase)).ToList() ?? []; + if (matches.Count == 0) + return Respond(string.Join('\n', new[] + { + $"# {path}", "", + $"_No top-level entry in this file has an x:Key, TargetType or element name containing \"{findText}\". Call get_wpf_theme_resource(\"{path}\") without find to see what the file contains._", + })); + var matchPages = XamlPages.Paginate( + new[] { (parts!.Head, "") }.Concat(matches.Select(e => (e.Text, e.Label))).Append((parts.Tail, "")), ThemePageChars); + if (PageError(matchPages.Count) is { } findPageError) return Respond(findPageError, isError: true); + var n = page ?? 1; + return Respond(Xaml( + $"# {path}: {matches.Count} entr{(matches.Count == 1 ? "y" : "ies")} matching \"{findText}\"{(matchPages.Count > 1 ? $" (page {n} of {matchPages.Count})" : "")}", + "_The matching top-level entries in full, inside the file's own root element so its xmlns declarations come along. " + + "They may use other resources via StaticResource — defined in this file or in a file its MergedDictionaries list " + + "(e.g. an implicit style BasedOn a keyed one in .Styles.xaml); get those the same way, by key." + + (matchPages.Count > 1 ? $" The matches take {matchPages.Count} pages; {(n < matchPages.Count ? $"next: page: {n + 1}." : "this is the last one.")}" : "") + "_", + matchPages[n - 1].Text)); + } - log.Write("get_wpf_theme_resource", input, text, timer); - return ToolResult.Text(text); + // Small files: the whole file, exactly as before. + if (content.Length <= MaxThemeFileChars) + { + if (PageError(1) is { } smallPageError) return Respond(smallPageError, isError: true); + return Respond(string.Join('\n', new[] { $"# {path}", "", mechanismNote, "", "```xml", content, "```" })); + } + + var pages = XamlPages.Paginate( + parts is null ? [(content, "")] : new[] { (parts.Head, "") }.Concat(parts.Entries.Select(e => (e.Text, e.Label))).Append((parts.Tail, "")), + ThemePageChars); + if (PageError(pages.Count) is { } pageError) return Respond(pageError, isError: true); + + if (page is { } pageNumber) + return Respond(Xaml( + $"# {path} (page {pageNumber} of {pages.Count})", + $"_Pages split between top-level elements (an element bigger than a page continues on the next) and concatenate to the exact file. " + + $"{(pageNumber < pages.Count ? $"Next: page: {pageNumber + 1}." : "This is the last page.")}_", + pages[pageNumber - 1].Text)); + + // Large file, no page or find: an overview of what is on each page. + var lead = new List + { + $"# {path}", "", mechanismNote, "", + $"_This file is {content.Length} characters, more than one response holds, so it comes in {pages.Count} pages that split between top-level elements " + + "(an element bigger than a page continues on the next). Read it in order with page: 1, 2, … — the pages concatenate to the exact file — " + + "or get specific styles, templates or brushes in full with find: \"\"._", + "", "## Contents", + }; + string Label(string label) => $"`{label}`"; + var full = pages.Select((p, i) => $"- Page {i + 1}: {(p.Labels.Count > 0 ? string.Join(", ", p.Labels.Select(Label)) : "(continued)")}").ToList(); + var overview = string.Join('\n', lead.Concat(full)); + if (overview.Length > MaxThemeFileChars) + // The full list doesn't fit (a few brush files have 700+ entries): first and last entry per page. + overview = string.Join('\n', lead.Concat(pages.Select((p, i) => p.Labels.Count switch + { + 0 => $"- Page {i + 1}: (continued)", + 1 => $"- Page {i + 1}: {Label(p.Labels[0])}", + _ => $"- Page {i + 1}: {p.Labels.Count} entries, {Label(p.Labels[0])} … {Label(p.Labels[^1])}", + })).Append("").Append("_The full list of entries doesn't fit here; use find to get specific ones._")); + return Respond(overview); } // ── get_wpf_theme_palette ──────────────────────────────────────────────── @@ -361,40 +441,126 @@ private static List ParsePalette(string xaml) .Select(t => (t.theme, t.file!)) .ToArray(); - private static readonly string PaletteGuidance = string.Join('\n', new[] + // ── recolor guidance ───────────────────────────────────────────────────── + // Verified in test apps (Infragistics 26.1.54; RoyalDark and IG; chart, grid, gauge, DockManager, + // standard WPF controls): overriding palette keys never recolors a ThemeManager theme — each theme + // file merges its own copy of .Theme.Colors.xaml and resolves it with StaticResource, and + // ThemeManager appends its dictionary after the app's. Control-level overrides and an extended + // theme (ThemeBase subclass remapping files) do work. + + [GeneratedRegex(@"")] + private static partial Regex CompiledThemeClassTag(); + + /// The compiled theme class a ThemeManager wrapper file merges, read from the file itself so the + /// per-theme names stay exact: `DataPresenterRoyalDark`, but `DataPresenterIGTheme` for IG. + private string? CompiledThemeClass(ThemeResourceFile[] files, string fileSuffix, string classPrefix) { - "## How to apply a re-tuned palette", - "", - "These keys are the single re-color surface for the **newer \"Infragistics.Controls.*\" family** (charts, gauges, etc.) applied via `Infragistics.Themes.ThemeManager`. To recolor the theme WITHOUT creating a new theme, override just the keys you want in your own `ResourceDictionary` and merge it into the theme load — do NOT invent new keys or rename existing ones.", - "", - "⚠️ **Load-order matters.** The theme references these colors from compiled BAML primitives via `StaticResource` (resolved once at parse time), so merging an override dictionary *after* the theme has already loaded may NOT recolor already-styled controls. Merge your override so it is present BEFORE `ThemeManager.ApplicationTheme` is set / before the first themed window is created (e.g. in `App.xaml.cs` before `base.OnStartup`).", - "", - "⚠️ **Newer family only.** Legacy \"Infragistics.Windows.*\" controls (XamDataGrid, XamRibbon, XamDockManager, ...) do NOT read this palette — their themes are embedded BAML applied via `Theme=\"...\"`. Recolor those by copying individual Styles/ControlTemplates (see `setup_wpf_theme` / `get_wpf_theme_resource`).", - "", - "_This is a read-only introspection tool — it returns the real keys/values and a skeleton to copy; it does not modify your project._", - }); + var file = files.FirstOrDefault(f => f.File.EndsWith(fileSuffix, StringComparison.OrdinalIgnoreCase)); + var xaml = file is null ? null : store.LoadThemeResource(file.Path); + return xaml is null ? null : CompiledThemeClassTag().Matches(xaml) + .Select(m => m.Groups[1].Value) + .FirstOrDefault(n => n.StartsWith(classPrefix, StringComparison.Ordinal) && !n.EndsWith("Brushes", StringComparison.Ordinal)); + } + + /// `` as the file is actually named in the theme folder. + private static string ThemeFile(ThemeResourceFile[] files, string theme, string suffix) => + files.FirstOrDefault(f => f.File.Equals(theme + suffix, StringComparison.OrdinalIgnoreCase))?.File ?? theme + suffix; + + private string RecolorGuidance(string theme, ThemeResourceFile[] files) + { + var themeClass = ThemeClassName(theme); + var dpClass = CompiledThemeClass(files, ".xamDataPresenter.xaml", "DataPresenter") ?? $"DataPresenter{theme}"; + var dpPrimitives = CompiledThemeClass(files, ".xamDataPresenter.xaml", "Primitives") ?? $"Primitives{theme}"; + var dockClass = CompiledThemeClass(files, ".xamDockManager.xaml", "DockManager") ?? $"DockManager{theme}"; + var dockPrimitives = CompiledThemeClass(files, ".xamDockManager.xaml", "Primitives") ?? dpPrimitives; + var colorsFile = ThemeFile(files, theme, ".Theme.Colors.xaml"); + var stylesFile = ThemeFile(files, theme, ".Styles.xaml"); + var chartFile = ThemeFile(files, theme, ".xamDataChart.xaml"); + var msFile = ThemeFile(files, theme, ".MSControls.Core.Implicit.xaml"); + + return $$$""" + ## How to recolor {{{theme}}} (verified) + + Read the palette above as a **map** of which color plays which role, and use it to pick matching replacement colors. **Overriding these keys does not recolor the theme**: every theme file merges its own private copy of `{{{colorsFile}}}` and resolves the keys against it, and ThemeManager adds its dictionary after the app's resources. Verified in test apps on RoyalDark and IG for charts, grids, gauges, DockManager and standard WPF controls; all ThemeManager themes are built the same way. + + **Never works** + - Overriding the palette keys (`Color_…`, `Brush01`–`Brush05`): the theme files never read your copy. + - Putting overrides in a ResourceDictionary that App.xaml merges (`Application.Resources.MergedDictionaries`): ThemeManager's dictionary is added after it and wins. + - ResourceWasher: it tints every color (backgrounds too), can't hit an exact brand color, and crashes on the compiled grid theme. The Infragistics Color Tuner only generates ResourceWashers merged in App.xaml, so it doesn't work either. + + **Where overrides work:** on the control itself, in the control's or window's `Resources`, or in a dictionary added in code **after** the theme is set (verified for the gauge, standard controls and DockManager): + ```csharp + Infragistics.Themes.ThemeManager.ApplicationTheme = new Infragistics.Themes.{{{themeClass}}}(); + Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri("BrandColors.xaml", UriKind.Relative) }); + ``` + For the grid and DockManager, overrides placed directly in `Application.Resources` (not in a merged dictionary) work too. + + ### Recolor specific controls + - **Chart (`XamCategoryChart`):** set `Brushes` and `Outlines` (each an `ig:BrushCollection`) on the chart. + - **Grid (`XamDataGrid`):** Styles based on the theme's own grid styles, in the grid's `Resources` or directly in `Application.Resources`: + ```xml + + + + ``` + Excel-style filter checkboxes need their own implicit `HierarchicalDataTemplate DataType="{x:Type igDP:RecordFilterTreeItem}"` with a CheckBox style. Check the text contrast too: light themes such as IG keep dark selected text, so also set `ForegroundSelectedStyle`, `ForegroundActiveStyle` and (on `CellValuePresenter`) `ForegroundFieldSelectedStyle` to a style with a readable `TextBlock.Foreground`. + - **DockManager:** override its brush keys (`igDock:DockManagerBrushKeys`, xmlns `http://infragistics.com/DockManager`) app-wide — directly in `Application.Resources` or in a dictionary added after the theme. On RoyalDark, `TabbedDocumentActiveOuterBorderFillKey` draws the selected tab line and `MenuItemCheckmarkBoxHottrackCenterFillKey` the checked menu item; every key and its color is in `DefaultStyles/DockManager/{{{dockClass}}}_Brushes.xaml`. Overrides on the `XamDockManager` itself miss floating windows. + - **Gauge (`XamRadialGauge`):** set `RangeBrushes` on the gauge. For an app-wide gauge Style (`BasedOn="{StaticResource {x:Type ig:XamRadialGauge}}"`), load the gauge type right after setting the theme — `System.Runtime.CompilerServices.RuntimeHelpers.RunClassConstructor(typeof(Infragistics.Controls.Gauges.XamRadialGauge).TypeHandle);` — or the gauge silently loses the theme. + - **Standard WPF controls (Button, CheckBox, RadioButton, ListBoxItem, TextBox):** re-declaring the theme's brush keys near the control does nothing — the templates hold frozen brushes. Copy the control's template from `{{{stylesFile}}}` with the accent brushes swapped, or use the extended theme below. A `ProgressBar` only needs `Foreground`. A Button shows the accent only when focused or pressed. + + ### Recolor the whole app (extended theme) + Subclass the theme and point ThemeManager at copied, recolored theme files: + ```csharp + public class BrandTheme : Infragistics.Themes.{{{themeClass}}} + { + protected override void ConfigureControlMappings() + { + base.ConfigureControlMappings(); + var chart = BuildLocationString(typeof(BrandTheme).Assembly.FullName, "/Themes/Brand/{{{chartFile}}}"); + foreach (var key in Mappings.Keys.ToList()) + if (Mappings[key].EndsWith("{{{chartFile}}}", StringComparison.OrdinalIgnoreCase)) // compare case-insensitively: registered names can differ in case + Mappings[key] = chart; + } + } + // App.xaml.cs: Infragistics.Themes.ThemeManager.ApplicationTheme = new BrandTheme(); + ``` + - **Charts, gauges, standard controls and other controls:** copy the control's theme file and the files it merges (such as `{{{stylesFile}}}` and `{{{colorsFile}}}`) into your project, change the accent colors in the copied `{{{colorsFile}}}`, and remap that file as above. Standard WPF controls use `{{{msFile}}}`. + - **Grid:** its theme file only wraps compiled themes. Remap it to your own dictionary that merges `` plus copies of the grid's theme files from `DefaultStyles/DataPresenter/` — `{{{dpClass}}}.xaml`, `{{{dpClass}}}_Express.xaml`, `{{{dpClass}}}_Brushes.xaml` and the `DataPresenterGeneric*.xaml` files they merge, with the `Images` folder as `Resource` items — and change the accent in the copied `{{{dpClass}}}_Brushes.xaml`. If the Excel-style filter checkboxes keep the old color (seen on IG), they are styled by the standard-controls file, `{{{msFile}}}` — remap that too. + - **DockManager:** remap its file to a dictionary that merges ``, `` and your brush-key overrides — no copied templates needed. + - Copied theme files must be re-copied on every Infragistics upgrade. `get_wpf_theme_resource` returns them completely: large files come in pages that concatenate to the exact file (page: 1, 2, …), and find: "" returns single entries in full. + + _Read-only tool: it returns the theme's real keys and verified steps; it does not modify your project._ + """; + } private static string BuildPaletteChooser(IEnumerable themeNames) => string.Join('\n', new[] { "# WPF Theme Palettes — pick a base theme", "", - "These newer-family (ThemeManager) themes expose a re-tunable color palette:", + "These ThemeManager themes have a color palette:", "", } .Concat(themeNames.Select(t => $"- `{t}`")) .Concat(new[] { "", - "## To re-color WITHOUT knowing the theme name", + "## To recolor WITHOUT knowing the theme name", "", "1. **Detect the theme the app already uses** from the workspace, in priority order:", - " - `App.xaml.cs` → `Infragistics.Themes.ThemeManager.ApplicationTheme = new Theme();`", + " - `App.xaml.cs` → `Infragistics.Themes.ThemeManager.ApplicationTheme = new Theme();` (the IG theme's class is `IgTheme`)", " - the `.csproj` → an `Infragistics.WPF.Themes..Trial` PackageReference", " - XAML → a `Theme=\"\"` attribute", "2. If you cannot detect it, **ask the user** which base theme they want — or simply whether they want a **dark** base (e.g. `MetroDark`, `RoyalDark`) or a **light** base (e.g. `Office2013`, `RoyalLight`, `Metro`, `IG`). _This tool cannot classify dark/light automatically — the palette files are not consistent enough to derive it reliably._", - "3. Call `get_wpf_theme_palette(theme)` with the chosen name to get its keys + a ready-to-merge override skeleton.", + "3. Call `get_wpf_theme_palette(theme)` with the chosen name to get its colors plus the verified recolor steps for that theme.", "", - "_Concrete colors the user gives (e.g. \"neon purple\") are mapped to hex and assigned to the returned accent/chart-series keys by you — the tool only supplies the grounded keys._", + "_Overriding the palette keys does not recolor a theme; the steps returned in step 3 say what does. Concrete colors the user gives (e.g. \"neon purple\") are mapped to hex by you — use the palette to see which colors play which role._", })); [McpServerTool(Name = "get_wpf_theme_palette", ReadOnly = true, Idempotent = true, OpenWorld = false)] @@ -403,11 +569,12 @@ public CallToolResult GetThemePalette( [Description(""" A newer-family (ThemeManager) theme name, e.g. "MetroDark", "RoyalDark", "RoyalLight", "Office2013", "Office2010Blue", "Metro", "IG". Case-insensitive; a substring is accepted. - Returns that theme's centralized color/brush palette (its `.Theme.Colors.xaml`) so you can - re-color the theme by overriding existing keys — NOT create a new theme. + Returns that theme's color/brush palette (its `.Theme.Colors.xaml`) as a map of which color + plays which role, plus the verified steps to recolor that theme (overriding the palette keys does NOT + recolor it). OMIT this to get the chooser: the list of palette-capable theme names plus how to detect which theme the app already uses. Detect the current theme from the workspace FIRST (App.xaml.cs - `ThemeManager.ApplicationTheme = new Theme();`, an `Infragistics.WPF.Themes..Trial` + `ThemeManager.ApplicationTheme = new Theme();` or `new IgTheme()`, an `Infragistics.WPF.Themes..Trial` PackageReference, or a `Theme=""` attribute); if you cannot, ask the user which base theme (or dark vs light) they want, then call again with that theme. """)] @@ -441,7 +608,7 @@ Optional substring filter matched against the palette group label (e.g. "accent" if (match.file is null) { var names = string.Join(", ", available.Select(t => $"`{t.theme}`")); - var msg = $"No re-tunable palette found for theme \"{theme}\". Themes with a color palette (newer ThemeManager family): {names}. Call setup_wpf_theme to browse all themes and style folders."; + var msg = $"No color palette found for theme \"{theme}\". Themes with a color palette (newer ThemeManager family): {names}. Call setup_wpf_theme to browse all themes and style folders."; log.Write("get_wpf_theme_palette", input, msg, timer); return ToolResult.Error(msg); } @@ -503,27 +670,9 @@ Optional substring filter matched against the palette group label (e.g. "accent" output.Add($"| `{e.Key}` | `{e.Value}`{(e.Kind == "Brush" ? " _(brush)_" : "")} | {e.Note ?? ""} |"); } + var themeFiles = store.ThemeIndex.NewerThemes.First(t => t.Theme == match.theme).Files; output.Add(""); - output.Add("## Override skeleton"); - output.Add(""); - output.Add("_Copy into a `ResourceDictionary`, change only the values you want, and merge it ahead of the theme load (see guidance below). Delete rows you are not changing._"); - output.Add(""); - output.Add("```xml"); - output.Add(""); - foreach (var g in byGroup) - { - output.Add($" "); - foreach (var e in g) - output.Add(e.Kind == "Brush" - ? $" " - : $" {e.Value}"); - } - output.Add(""); - output.Add("```"); - - output.Add(""); - output.Add(PaletteGuidance); + output.Add(RecolorGuidance(match.theme, themeFiles)); var text = string.Join('\n', output); log.Write("get_wpf_theme_palette", input, text, timer); diff --git a/server/Tools/ToolDescriptions.cs b/server/Tools/ToolDescriptions.cs index c278c89..65cde92 100644 --- a/server/Tools/ToolDescriptions.cs +++ b/server/Tools/ToolDescriptions.cs @@ -25,6 +25,8 @@ public static class ToolDescriptions Use after list_wpf_components (or search_wpf_api, if the exact type name isn't known yet) to resolve the component name. Also use to check whether a specific property or event exists on a known type — it returns the complete member list, including members inherited from Infragistics base classes (grouped under "inherited from `Base`" headings). + The xmlns line is resolved from the type's own assembly, so use it exactly as given — it can differ between types of one control (e.g. igDP for XamDataGrid, igThemes for DataPresenterRoyalDark, igPrim for primitives, or a clr-namespace: line). Static resource keys and commands (e.g. DockManagerBrushKeys.*, DataPresenterCommands.*) are listed with the properties, marked "(static field)" — use them via {x:Static prefix:Type.Member}. Nested classes show a "XAML name" (Outer+Inner). + Large types (mostly charts and grids, whose members are largely inherited) are compacted to stay under ~25K characters, giving up detail in this order until the response fits: events and methods are listed by name only first (properties keep full detail), then inherited members, and as a last resort all members. The response then says so in an "ℹ️ Compacted" line. Every member name is always listed. For full properties only, kind: "properties" is often small enough to come back uncompacted. To get full details (type, enum values, summary) for specific members, pass member: a case-insensitive name substring, combinable with kind — e.g. get_wpf_api_reference("XamCategoryChart", member: "YAxis") returns every YAxis* member, inherited ones included. When you already know which members you need, pass member up front. If a type's own member list is unexpectedly sparse, the response names its base type — call get_wpf_api_reference again on that base type to see the rest of the inherited surface. @@ -61,7 +63,7 @@ public static class ToolDescriptions Returns a ranked list of matching topics with slug, title, controls, tags, and a short summary — NOT the full content. This is a lookup step, not the final answer: always follow up with get_wpf_doc(slug) on the most relevant result(s) before writing any code or giving usage advice. - For APPLYING or RE-COLORING a named theme, do not rely on these docs — use setup_wpf_theme (which mechanism applies + the exact ready-to-paste apply steps) and get_wpf_theme_palette (the centralized palette keys) instead; those tools are authoritative for theming, the docs here only cover conceptual/how-to background. + For APPLYING or RE-COLORING a named theme, do not rely on these docs — use setup_wpf_theme (which mechanism applies + the exact ready-to-paste apply steps) and get_wpf_theme_palette (the theme's colors plus verified recolor steps) instead; those tools are authoritative for theming, the docs here only cover conceptual/how-to background. """; public const string GetWpfDoc = """ @@ -76,7 +78,7 @@ public static class ToolDescriptions List available Infragistics WPF named themes and the raw XAML style/resource-dictionary files backing them, sourced directly from the official Infragistics/wpf-resources repository. Covers BOTH theming mechanisms used across the product — they are NOT interchangeable, always check which one applies before writing code: 1. Legacy "Infragistics.Windows.*" family (XamDataGrid/XamDataPresenter, XamRibbon, XamDockManager, XamTabControl, etc.) — named themes (MetroDark, RoyalDark, Office2013, ...) ship embedded as BAML **inside the component's own assembly**. Apply out-of-the-box by simply setting `Theme="MetroDark"` on the control — no package or merge needed. The matching files under `DefaultStyles/{Folder}/` are for *customizing*: copy the relevant Style/ControlTemplate and override just what you need. - 2. Newer "Infragistics.Controls.*" family (charts, gauges, and other newer controls) — themes are NOT embedded; apply via a separate `Infragistics.WPF.Themes..Trial` NuGet package plus `Infragistics.Themes.ThemeManager.ApplicationTheme = new Theme();` in App.xaml.cs (do NOT manually merge the `Themes/{ThemeName}/` files into Application.Resources — that throws a runtime XamlParseException). The matching files under `Themes/{ThemeName}/` are for inspecting/customizing the exact brushes and templates that theme applies. + 2. Newer "Infragistics.Controls.*" family (charts, gauges, and other newer controls) — themes are NOT embedded; apply via a separate `Infragistics.WPF.Themes..Trial` NuGet package plus `Infragistics.Themes.ThemeManager.ApplicationTheme = new Theme();` in App.xaml.cs (the IG theme's class is `IgTheme`; do NOT manually merge the `Themes/{ThemeName}/` files into Application.Resources — that throws a runtime XamlParseException). The matching files under `Themes/{ThemeName}/` are for inspecting/customizing the exact brushes and templates that theme applies. Call this FIRST for any styling/theming task — before writing XAML, before advising which mechanism applies, and before calling get_wpf_theme_resource. For newer-family themes it also returns the COMPLETE ready-to-paste apply block (the `dotnet add package Infragistics.WPF.Themes..Trial` line plus the `App.xaml.cs` ThemeManager call), so applying a real theme is fewer steps than hand-authoring brushes — always prefer it over inventing your own color scheme. Pass `component` and/or `theme` to filter; omit both to see the full list of theme names and legacy style folders. Returns exact `path` values — reuse them verbatim in get_wpf_theme_resource(path) to read the actual XAML content. """; @@ -86,14 +88,18 @@ public static class ToolDescriptions Use the exact `path` returned by setup_wpf_theme (e.g. "Themes/MetroDark/MetroDark.xamDataChart.xaml" or "DefaultStyles/Ribbon/RibbonMetroDark.xaml") — never guess this path. This is always the second step after setup_wpf_theme, never the first call. - Use this when the user wants to customize/override part of a control's themed appearance (copy the real Style/ControlTemplate as a starting point instead of guessing property names or template structure), or wants to see exactly what a named theme changes for a given control. Large files are truncated with a note — ask for a narrower/more specific file via setup_wpf_theme if you need a part that got cut off. + Use this when the user wants to customize/override part of a control's themed appearance (copy the real Style/ControlTemplate as a starting point instead of guessing property names or template structure), or wants to see exactly what a named theme changes for a given control. + + Nothing is cut off. Files up to ~24K characters come back whole. Larger ones (about half of them, up to ~660K) first return a contents overview: how many pages there are and which entries (x:Key or TargetType) start on each. Then either: + • find: "" — just the matching Style/ControlTemplate/brush entries, each in full, wrapped in the file's own root element so its xmlns declarations come along (e.g. find: "CheckBox"). The fastest way to the one style you need. An entry may be BasedOn or reference resources in a file its MergedDictionaries list (e.g. .Styles.xaml) — get those the same way. + • page: 1, 2, … — the file in order. Pages split between top-level elements (one bigger than a page continues on the next), and concatenated they are the exact file, e.g. for copying a whole theme file. """; public const string GetWpfThemePalette = """ - Return the centralized color/brush PALETTE of one newer-family (ThemeManager) Infragistics WPF theme — the exact `` and `` resource keys, current values, and semantic groupings parsed from that theme's official `.Theme.Colors.xaml`. Use this for the specific task of RE-COLORING an existing theme (tuning the palette) WITHOUT creating a brand-new theme. + Return the color/brush PALETTE of one newer-family (ThemeManager) Infragistics WPF theme — the exact `` and `` resource keys, current values, and semantic groupings parsed from that theme's official `.Theme.Colors.xaml` — plus the VERIFIED steps to recolor that theme (brand colors, a different accent) without creating a brand-new theme. - Returns: the real keys (e.g. `Color_024` accent, `Color_028` error, base grays `Color_000`-`Color_009`, chart-series colors, plus the 5 primary "Color Tuner" `Brush01`-`Brush05`), grouped using the file's own section labels (never guessed); a ready-to-merge override `ResourceDictionary` skeleton pre-filled with those verbatim keys; and application guidance including the load-order caveat (the theme reads these via StaticResource from compiled BAML, so an override must be merged BEFORE the theme loads to take effect) and the fact that this palette applies ONLY to the newer "Infragistics.Controls.*" family, not legacy embedded-BAML controls. + Returns: the real keys (e.g. `Color_024` accent, `Color_028` error, base grays `Color_000`-`Color_009`, chart-series colors, plus the 5 primary `Brush01`-`Brush05`), grouped using the file's own section labels (never guessed), to read as a map of which color plays which role; then recolor steps for that theme. ⚠️ Overriding these palette keys does NOT recolor the theme (every theme file merges its own private copy of the palette, and ThemeManager adds its dictionary after the app's), and an override ResourceDictionary merged in App.xaml never wins. What works, verified in test apps on charts, grids, gauges, DockManager and standard WPF controls: control-level overrides (chart `Brushes`, grid Styles `BasedOn` the theme's compiled grid styles, DockManager brush keys, gauge `RangeBrushes`, copied templates for standard controls), or an extended theme — a subclass of the theme class that remaps theme files to copied, recolored ones. The steps name the exact class and file names for the requested theme. - Only newer-family themes have a palette (e.g. MetroDark, RoyalDark, RoyalLight, Office2013, Office2010Blue, Metro, IG). Pass `theme` (case-insensitive; substring accepted) and optionally `filter` to narrow to a group/key/value (e.g. "accent", "chart", "Brush01", "00AADE"). If the user does NOT name a theme, detect the one the app already uses from the workspace FIRST — App.xaml.cs `ThemeManager.ApplicationTheme = new Theme();`, an `Infragistics.WPF.Themes..Trial` PackageReference, or a `Theme=""` attribute — and if you cannot, call this tool with NO `theme` to get the chooser (palette-capable theme names + detection steps) and ask the user for a dark vs light base. When the user gives concrete colors (e.g. "neon purple"), YOU translate them to hex and assign them to the accent/chart-series keys this tool returns — the tool supplies the grounded keys, it does not do color mapping. Prefer this over get_wpf_theme_resource when the user's goal is "change the colors / palette of theme X" rather than reading one control's full template. This is a read-only introspection tool — it does not modify the user's project. + Only newer-family themes have a palette (MetroDark, RoyalDark, RoyalLight, Office2013, Office2010Blue, Metro, IG). Pass `theme` (case-insensitive; substring accepted) and optionally `filter` to narrow to a group/key/value (e.g. "accent", "chart", "Brush01", "00AADE"). If the user does NOT name a theme, detect the one the app already uses from the workspace FIRST — App.xaml.cs `ThemeManager.ApplicationTheme = new Theme();` (the IG theme's class is `IgTheme`), an `Infragistics.WPF.Themes..Trial` PackageReference, or a `Theme=""` attribute — and if you cannot, call this tool with NO `theme` to get the chooser (palette-capable theme names + detection steps) and ask the user for a dark vs light base. When the user gives concrete colors (e.g. "neon purple"), YOU translate them to hex — the tool supplies the grounded palette and steps, it does not do color mapping. Prefer this over get_wpf_theme_resource when the user's goal is "change the colors of theme X" rather than reading one control's full template. This is a read-only introspection tool — it does not modify the user's project. """; } diff --git a/server/Tools/ToolInput.cs b/server/Tools/ToolInput.cs new file mode 100644 index 0000000..11ee693 --- /dev/null +++ b/server/Tools/ToolInput.cs @@ -0,0 +1,18 @@ +namespace Infragistics.Wpf.Mcp.Tools; + +/// Input checks the SDK doesn't do: [MinLength]/[MaxLength]/[Range] on a tool parameter only +/// describe the schema, so each tool enforces its limits itself before doing any work. +public static class ToolInput +{ + /// The error message when `value` is outside [min, max] characters, otherwise null. Pass the + /// same numbers as the parameter's attributes. Null counts as empty, and so does whitespace + /// for the minimum. + public static string? CheckLength(string name, string? value, int max, int min = 0) + { + var length = value?.Length ?? 0; + if (length > max) return $"`{name}` is too long ({length} characters), max {max} characters."; + if ((value?.Trim().Length ?? 0) < min) + return min == 1 ? $"`{name}` must not be empty." : $"`{name}` is too short, min {min} characters."; + return null; + } +} diff --git a/server/Tools/ToolLog.cs b/server/Tools/ToolLog.cs index e545934..1139fa6 100644 --- a/server/Tools/ToolLog.cs +++ b/server/Tools/ToolLog.cs @@ -8,6 +8,9 @@ namespace Infragistics.Wpf.Mcp.Tools; public sealed class ToolLog { private readonly bool _enabled; + // Tool calls run concurrently and File.AppendAllText doesn't share the file with another + // writer, so unserialized appends collide and the entry is lost. Covers this process only. + private readonly Lock _gate = new(); public string Path { get; } public ToolLog(bool enabled) @@ -24,8 +27,8 @@ public void Write(string tool, object input, string output, Stopwatch timer) var preview = output.Length > 400 ? $"{output[..400]}… ({output.Length} chars)" : output; try { - File.AppendAllText(Path, - $"[{DateTime.UtcNow:yyyy-MM-dd'T'HH:mm:ss.fff'Z'}] {tool} ({timer.ElapsedMilliseconds}ms)\n IN: {JsonSerializer.Serialize(input)}\n OUT: {preview}\n\n"); + var entry = $"[{DateTime.UtcNow:yyyy-MM-dd'T'HH:mm:ss.fff'Z'}] {tool} ({timer.ElapsedMilliseconds}ms)\n IN: {JsonSerializer.Serialize(input)}\n OUT: {preview}\n\n"; + lock (_gate) File.AppendAllText(Path, entry); } catch { diff --git a/server/Tools/XamlPages.cs b/server/Tools/XamlPages.cs new file mode 100644 index 0000000..3c73640 --- /dev/null +++ b/server/Tools/XamlPages.cs @@ -0,0 +1,131 @@ +using System.Text; +using System.Xml; + +namespace Infragistics.Wpf.Mcp.Tools; + +/// Splits a XAML resource dictionary into pages at its top-level elements, so a theme file of any size can be read +/// completely: the pages concatenate back to the exact file. On the 26.1.21 data, 436 of 822 theme files are over +/// the 24,000-character cut-off get_wpf_theme_resource used to apply, up to 659K (RibbonGeneric.xaml, 33 pages). +internal static class XamlPages +{ + /// One top-level element with the comments and indentation that lead into it. + internal sealed record Entry(string Text, string Label, string Search); + + /// Head: prolog and root start tag. Tail: root end tag and anything after it. + internal sealed record Parts(string Head, IReadOnlyList Entries, string Tail); + + internal sealed record Page(string Text, IReadOnlyList Labels); + + /// The file split into top-level elements, or null if it isn't well-formed XML or has no child elements. + public static Parts? Parse(string text) + { + var lineStarts = new List { 0 }; + for (var i = 0; i < text.Length; i++) + if (text[i] == '\n') lineStarts.Add(i + 1); + int Offset(int line, int position) => lineStarts[line - 1] + position - 1; + + var starts = new List<(int At, string Label, string Search)>(); + int? leadingComment = null; + var rootEnd = -1; + try + { + // Namespaces = false: a few Infragistics files use a prefix they never declare (DataPresenterHulk.xaml). + using var reader = new XmlTextReader(new StringReader(text)) + { + Namespaces = false, DtdProcessing = DtdProcessing.Ignore, WhitespaceHandling = WhitespaceHandling.All, + }; + var positioned = false; // Skip() already moved to the next node + while (positioned || reader.Read()) + { + positioned = false; + if (reader.Depth == 1 && reader.NodeType == XmlNodeType.Comment) + { + leadingComment ??= text.LastIndexOf("