Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/review-fixtures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,7 @@ jobs:
-ChangedPathsFile "$env:RUNNER_TEMP/changed-paths.txt" `
-CoverageReportPath "$env:RUNNER_TEMP/review-coverage.json"
Get-Content -LiteralPath "$env:RUNNER_TEMP/review-coverage.json"

- name: Check context-dependent fixture regressions
shell: pwsh
run: ./tools/Test-ReviewFixtureContexts.ps1 -Root .
28 changes: 27 additions & 1 deletion evaluation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,32 @@ is mandatory.

Model-facing preparation hashes case IDs, neutralizes `Good`/`Bad` object-name tokens, and removes full-line sample comments so neither the article slug, domain, nor expected outcome reveals the answer.

When applicability depends on deployment facts that AL cannot establish, use
`articleOverrides` within the domain override, keyed by a selected article's
exact slug (without `.md`). Each entry requires a non-empty `context` string.
It replaces the domain-wide context for that article's positive and clean pair
and survives in all prepared request formats. Include all necessary deployment
facts, but no expected finding, article path, fix, or assessment of code quality.
Other selected articles retain their existing domain context, if any.

An article entry can also supply `cleanContexts`, an object mapping lowercase
hyphenated control names to non-empty context strings. Each control reuses the
article's `.bad.al` source with that context and expects no findings. Its stable
manifest ID is `<domain>-<slug>-clean-<name>`; model-facing IDs remain opaque.
Use this only when changing the deployment prerequisite makes the same source
legitimate. The MCP description article supplies Business Central online 28 and
MCP exposure to both normal cases, then adds a REST-only, non-MCP clean context.
This controls applicability without treating every API lacking `AboutText` as
a defect. Invalid or unselected article overrides fail validation.

The context regression check runs the real preparer and scorer, verifies all
three request formats, context isolation, unchanged neutralization, mandatory
clean controls, and rejection of findings on the non-MCP input:

```powershell
pwsh ./tools/Test-ReviewFixtureContexts.ps1 -Root .
```

The core AL samples for BLOB loading, isolated shipment events, expense-page
scope filters, and stream encoding include their minimal demo tables in each
companion independently. BLOB field types and shipment status members therefore
Expand Down Expand Up @@ -72,7 +98,7 @@ pwsh ./tools/Test-ReviewFixtures.ps1 -Root . -ChangedPathsFile .changed-paths.tx

2. For a fast/small model, use one fresh invocation per `request-case-*.json`. Each request embeds the exact leaf instructions, that domain's candidate index rows with authoritative paths, and one opaque case. The model opens only matching articles and copies finding IDs from `candidateArticles[].path`. Save each response with the matching `result-case-*.json` name in the same directory.

`request-<domain>.json` files provide optional leaf batches containing every selected case for that domain and identify the selected layer-owned skill path; save those as `result-<domain>.json`. A normal convention-selected domain has one bad/good pair, while an `articles` override contributes one pair per listed article. Directory scoring prefers `result-case-*.json` when present and otherwise falls back to `result-*.json`. `review-request.json` is an optional all-domains stress test for larger models. Neither batch form is the preferred fast-model profile.
`request-<domain>.json` files provide optional leaf batches containing every selected case for that domain and identify the selected layer-owned skill path; save those as `result-<domain>.json`. A normal convention-selected domain has one bad/good pair, while an `articles` override contributes one pair per listed article plus any registered context controls. Directory scoring prefers `result-case-*.json` when present and otherwise falls back to `result-*.json`. `review-request.json` is an optional all-domains stress test for larger models. Neither batch form is the preferred fast-model profile.

3. Save only this result shape:

Expand Down
13 changes: 11 additions & 2 deletions evaluation/review-fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,17 @@
"check-json-null-before-converting-values",
"format-exchanged-values-with-standard-format-9",
"api-page-least-privilege-write-access",
"set-text-encoding-on-streams-exchanged-externally"
]
"set-text-encoding-on-streams-exchanged-externally",
"describe-api-pages-for-mcp-with-abouttext"
],
"articleOverrides": {
"describe-api-pages-for-mcp-with-abouttext": {
"context": "Target Business Central online version 28. The API page and API query in this input are exposed as tools through Business Central's built-in MCP server.",
"cleanContexts": {
"non-mcp": "Target Business Central online version 28. The API page and API query in this input serve a REST integration only and are not exposed through MCP."
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Intended for exposure through the Business Central online MCP server.
page 50381 "WS Margin Line API Bad"
{
PageType = API;
Caption = 'marginLine';
APIPublisher = 'contoso';
APIGroup = 'salesMargin';
APIVersion = 'v1.0';
EntityName = 'marginLine';
EntitySetName = 'marginLines';
EntityCaption = 'Margin Line';
EntitySetCaption = 'Margin Lines';
ODataKeyFields = SystemId;
SourceTable = "Sales Line";
SourceTableView = where("Document Type" = filter(Quote | Order | Invoice), Type = const(Item));
Editable = false;
InsertAllowed = false;
ModifyAllowed = false;
DeleteAllowed = false;

layout
{
area(content)
{
repeater(records)
{
field(id; Rec.SystemId)
{
Caption = 'id';
Editable = false;
}
field(documentNumber; Rec."Document No.")
{
Caption = 'documentNumber';
}
field(itemNumber; Rec."No.")
{
Caption = 'itemNumber';
}
field(lineAmount; Rec."Line Amount")
{
Caption = 'lineAmount';
}
}
}
}
}

// This API query is also intended for exposure as an MCP tool.
query 50383 "WS Item Lines Query Bad"
{
QueryType = API;
APIPublisher = 'contoso';
APIGroup = 'salesMargin';
APIVersion = 'v1.0';
EntityName = 'itemSalesLine';
EntitySetName = 'itemSalesLines';
EntityCaption = 'Item Sales Line';
EntitySetCaption = 'Item Sales Lines';
// The entity caption omits the material document-type scope.
elements
{
dataitem(SalesLine; "Sales Line")
{
DataItemTableFilter = "Document Type" = filter(Quote | Order | Invoice), Type = const(Item);
column(id; SystemId) { }
column(documentNumber; "Document No.") { }
column(itemNumber; "No.") { }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Intended for exposure through the Business Central online MCP server.
page 50380 "WS Margin Line API Good"
{
PageType = API;
Caption = 'marginLine';
APIPublisher = 'contoso';
APIGroup = 'salesMargin';
APIVersion = 'v1.0';
EntityName = 'marginLine';
EntitySetName = 'marginLines';
EntityCaption = 'Margin Line';
EntitySetCaption = 'Margin Lines';
AboutText = 'One item line of an unposted sales quote, order or invoice, with its document number, item and line amount. Read-only; credit memos and non-item lines are not included.';
ODataKeyFields = SystemId;
SourceTable = "Sales Line";
SourceTableView = where("Document Type" = filter(Quote | Order | Invoice), Type = const(Item));
Editable = false;
InsertAllowed = false;
ModifyAllowed = false;
DeleteAllowed = false;

layout
{
area(content)
{
repeater(records)
{
field(id; Rec.SystemId)
{
Caption = 'id';
Editable = false;
}
field(documentNumber; Rec."Document No.")
{
Caption = 'documentNumber';
}
field(itemNumber; Rec."No.")
{
Caption = 'itemNumber';
}
field(lineAmount; Rec."Line Amount")
{
Caption = 'lineAmount';
}
}
}
}
}

// This API query is also intended for exposure as an MCP tool.
query 50382 "WS Item Lines Query Good"
{
QueryType = API;
APIPublisher = 'contoso';
APIGroup = 'salesMargin';
APIVersion = 'v1.0';
EntityName = 'itemSalesLine';
EntitySetName = 'itemSalesLines';
EntityCaption = 'Item Sales Line';
EntitySetCaption = 'Item Sales Lines';
AboutText = 'One item line of an unposted sales quote, order or invoice. Credit memos and non-item lines are excluded; returns the line ID, document number and item number.';
elements
{
dataitem(SalesLine; "Sales Line")
{
DataItemTableFilter = "Document Type" = filter(Quote | Order | Invoice), Type = const(Item);
column(id; SystemId) { }
column(documentNumber; "Document No.") { }
column(itemNumber; "No.") { }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
bc-version: [28..]
domain: web-services
keywords: [api-page, api-query, abouttext, mcp, tool-description, entitysetcaption, metadata]
technologies: [al]
countries: [w1]
application-area: [all]
---

# Describe API objects intended for Business Central MCP tools

## Description

For API pages and API queries exposed through the Business Central online MCP server, the documented tool-description precedence is `AboutText`, then `EntitySetCaption`, then a camel-cased `EntityName`. Treating `AboutText` only as a UI teaching-tip property can leave an agent with a caption that omits essential scope or computed-value semantics. This guidance covers BC 28 and later online environments with the relevant MCP API-object support; the earlier MCP preview is outside its scope.

## Best Practice

For an API object intended for MCP use, provide a tool description that explains what a row represents, material filters and any computed values that affect interpretation. Prefer `AboutText` when captions alone cannot convey those facts. Keep the description consistent with the actual filters, fields and allowed operations. Verify the description exposed by the configured MCP tool; metadata text does not enforce filters or permissions.

The documented extraction order does not use `AboutTitle`. Do not require it solely for MCP: Microsoft ships API queries with `AboutText` and no `AboutTitle`, and the compiler accepts that form. Teaching tips have separate pairing requirements. Do not flag an absent `AboutText` when the effective fallback already describes the tool adequately, or when the object has no established MCP use. Other MCP servers and connectors can choose a different description source; check their contract before applying this rule.

See sample: [`describe-api-pages-for-mcp-with-abouttext.good.al`](describe-api-pages-for-mcp-with-abouttext.good.al).

## Anti Pattern

An API object intended for an MCP tool exposes a description that omits material endpoint semantics, such as excluding credit memos or including only item lines, because it relies on a bare entity caption. Another defect is an `AboutText` that promises restrictions the endpoint does not implement. Missing `AboutText` alone is a discovery signal, not sufficient evidence of a finding; establish the intended MCP use and the deficient effective description.

See sample: [`describe-api-pages-for-mcp-with-abouttext.bad.al`](describe-api-pages-for-mcp-with-abouttext.bad.al).

## References

- [API page type and MCP description precedence](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-api-pagetype#describe-the-api-for-mcp-tools)
- [API query type and MCP description precedence](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-api-querytype#describe-the-api-for-mcp-tools)
- [AboutText property and teaching-tip requirements](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-abouttext-property)
- [BC 28 release and generally available enhanced MCP server](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/whatsnew/whatsnew-update-28-0)
- [API query support in the 2026 release wave 1 MCP enhancements](https://learn.microsoft.com/dynamics365/release-plan/2026wave1/smb/dynamics365-business-central/unlock-more-business-central-data-mcp-server-enhancements)
- [Microsoft API Finance query with AboutText and no AboutTitle](https://github.com/microsoft/BCApps/blob/e51c552efbeb6821db450b199aae6b015371866c/src/Apps/W1/APIReportsFinance/App/src/APIFinanceGLEntry.Query.al)
5 changes: 3 additions & 2 deletions microsoft/skills/review/al-web-services-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,16 @@ Discard files that are not applicable. Retain conditionally applicable files (an
Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against:

- The changed AL object names and types — especially pages declared with `PageType = API`, API page `part` controls, queries declared with `QueryType = API`, and procedures that expose bound actions.
- The changed properties and triggers, weighted toward API page metadata (`APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SourceTable`, `SourceTableTemporary`), navigation metadata (`SubPageLink`, `Multiplicity`, and visible singleton or collection semantics), CRUD guards (`InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`), the `OnOpenPage` trigger, and `OnValidate` triggers on exposed fields.
- The changed properties and triggers, weighted toward API page metadata (`APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `AboutText`, `EntitySetCaption`, `ODataKeyFields`, `SourceTable`, `SourceTableTemporary`), navigation metadata (`SubPageLink`, `Multiplicity`, and visible singleton or collection semantics), CRUD guards (`InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`), the `OnOpenPage` trigger, and `OnValidate` triggers on exposed fields.
- Outbound HTTP integration code that constructs or sends requests, captures a client method's optional Boolean result, checks an `HttpResponseMessage`, or reads and parses response content.
- Integration code that converts values to or from exchanged text: `Format` or `Evaluate` on a request URL, body, or file line, and `JsonToken`/`JsonValue` conversions of payload properties.
- Integration code that converts exchanged text to or from `Blob` bytes: trace encoding-sensitive operations such as `WriteText`/`ReadText` to `CreateOutStream`/`CreateInStream` with a missing or incompatible `TextEncoding`, relative to the counterpart's expected encoding or the incoming bytes — `set-text-encoding-on-streams-exchanged-externally`. Do not flag mere stream creation, binary content, compatible internal text round-trips, or byte-preserving download/HTTP/upload/`CopyStream` transfers of already encoded data. In particular, an explicit UTF-8 writer followed by a default-encoding InStream used only for download preserves the original bytes.
- Webhook subscriber handlers and subscription lifecycle code, especially code that creates or renews subscriptions, handles `validationToken`, schedules from `expirationDateTime`, or targets resources whose eligibility is visible in the diff.
- An API page or API query has established MCP use and its description metadata is added, changed, or omitted — `describe-api-pages-for-mcp-with-abouttext.md`. Read the article before evaluating the effective tool description.
- An API page (`PageType = API`) is added or changed and its `InsertAllowed`/`ModifyAllowed`/`DeleteAllowed` properties are left at their default `true` (or explicitly set `true`) for an operation the endpoint's stated purpose does not need — `api-page-least-privilege-write-access.md`. The signal is an operation left enabled beyond what the endpoint's own described purpose requires, not the mere presence of these properties; a page that genuinely needs full CRUD and grants it deliberately is not a violation.
- An API page (`PageType = API`) with `InsertAllowed = true` lists a field in `ODataKeyFields` and that same field control sets `Editable = false` — `api-page-key-fields-must-be-editable-on-insert.md`. A system-generated key such as `SystemId` marked read-only is not this anti-pattern.
- An API page exposes a field via `Rec` directly, and that field is a stored value computed from other fields inside an `OnValidate` trigger rather than a FlowField recalculated in `OnAfterGetRecord` — `stored-derived-fields-must-not-be-exposed-directly.md`. Exposing a genuine FlowField, or a value already recalculated in `OnAfterGetRecord`, is not this anti-pattern.
- Tokens extracted from the diff that relate to API surface and behaviour (`PageType`, `QueryType`, `API`, `api-page`, `page-part`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `ODataKeyFields`, `SystemId`, `SubPageLink`, `subpagelink`, `Multiplicity`, `multiplicity`, `Many`, `ZeroOrOne`, `SourceTableTemporary`, `Job Queue Entry`, `webhook`, `webhookSupportedResources`, `webhook-supported-resources`, `subscriptions`, `notificationUrl`, `validationToken`, `validationtoken`, `expirationDateTime`, `expirationdatetime`, `ServiceEnabled`, `WebServiceActionContext`, `SetActionResponse`, `ReadIsolation`, `IsolationLevel`, `ReadCommitted`, `InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`, `SourceTable`, `HttpClient`, `HttpRequestMessage`, `HttpResponseMessage`, `Get`, `Post`, `Put`, `Delete`, `Send`, `IsSuccessStatusCode`, `HttpStatusCode`, `Content`, `ReadAs`, `JsonObject`, `JsonToken`, `JsonValue`, `AsValue`, `IsNull`, `SelectToken`, `Format`, `Evaluate`, `XmlDocument`, `TextEncoding`, `CreateOutStream`, `CreateInStream`, `DownloadFromStream`, `UploadIntoStream`).
- Tokens extracted from the diff that relate to API surface and behaviour (`PageType`, `QueryType`, `API`, `api-page`, `page-part`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `AboutText`, `EntitySetCaption`, `ODataKeyFields`, `SystemId`, `SubPageLink`, `subpagelink`, `Multiplicity`, `multiplicity`, `Many`, `ZeroOrOne`, `SourceTableTemporary`, `Job Queue Entry`, `webhook`, `webhookSupportedResources`, `webhook-supported-resources`, `subscriptions`, `notificationUrl`, `validationToken`, `validationtoken`, `expirationDateTime`, `expirationdatetime`, `ServiceEnabled`, `WebServiceActionContext`, `SetActionResponse`, `ReadIsolation`, `IsolationLevel`, `ReadCommitted`, `InsertAllowed`, `ModifyAllowed`, `DeleteAllowed`, `Editable`, `SourceTable`, `HttpClient`, `HttpRequestMessage`, `HttpResponseMessage`, `Get`, `Post`, `Put`, `Delete`, `Send`, `IsSuccessStatusCode`, `HttpStatusCode`, `Content`, `ReadAs`, `JsonObject`, `JsonToken`, `JsonValue`, `AsValue`, `IsNull`, `SelectToken`, `Format`, `Evaluate`, `XmlDocument`, `TextEncoding`, `CreateOutStream`, `CreateInStream`, `DownloadFromStream`, `UploadIntoStream`).

A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone.

Expand Down
Loading
Loading