Repository navigation
feat: tool titles and hints, default-free schemas, contract tests over tools/list - #50
Merged
Merged
Conversation
…r tools/list
- Annotations: every native tool sets Title plus all four hints explicitly
(read-only tools idempotent; apply_fix/apply_fix_all non-destructive,
non-idempotent; openWorld=false everywhere). Titles and the pinned
description sentences live in Tools/ToolDescriptions.cs.
- Schemas: McpHost.NativeToolSchemaOptions strips the JSON-schema "default"
keyword from every node via TransformSchemaNode ("default": null was
published on every optional string). Parameter descriptions state the
non-null defaults instead; apply_fix_all.dryRun gained "Default: false.".
- Registration: native tools are registered by McpHost's own loop with
McpServerTool.Create(method, null, { Services = sp, SchemaCreateOptions }),
replacing WithToolsFromAssembly(), which cannot take schema options.
- McpHost.ConfigureServices is split out of RunAsync as the test seam.
- Contract tests (Contracts/ToolContractTests.cs) build the real container
under --no-proxy and check the tools/list wire shape: five tools and no
custom handlers, no "default" anywhere, properties/required equal to the
non-service parameters, titles and hints, pinned sentences, stated
defaults, analyze needs no arguments. The old analyze schema test built
the tool without Services (DI parameters leaked) and is removed.
- Docs: README native-tool table; AGENTS.md registration, annotation table,
schema transform and test seam.
Deviations from the issue: there is no WithToolsFromAssembly overload taking
McpServerToolCreateOptions (own loop instead); MoveDefaultKeywordToDescription
was rejected because it appends "(Default value: null)"; CI tests run on
Ubuntu only; the README had no tool table to update, so one was added.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Pin the published parameters as literals: InputSchema_PublishesThePinnedParameters checks the exact ordered properties and the required set per tool; the reflection-based test cannot catch a renamed or dropped parameter. - NativeToolMethods enumerates every [McpServerTool] method (any visibility, static or instance) and throws InvalidOperationException naming one that is not public static, instead of silently leaving it out of tools/list. A contract test pins the public-static convention. - The contract fixture finds the tool's MethodInfo via Metadata.OfType<MethodInfo>() with an explicit assertion, instead of relying on Metadata[0]. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Test the default (proxy-on) registration: a second container built without
--no-proxy (no host, AlMcpProxy never resolved) still publishes the same five
native tools in ToolCollection and registers the list/call handlers.
- ParameterDescriptions_StateEveryNonNullDefault now requires the default
value next to the word "default" ("(default 500)", "Default: false.",
"'project' (default, ...") instead of both appearing anywhere in the text.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- The default-wording check in ParameterDescriptions_StateEveryNonNullDefault now requires a left word boundary before the value in its first alternative, so "(default 1500)" no longer counts as stating 500. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The published
tools/listis the only contract most MCP clients see. This PR makes the five native tools publish a complete, clean contract and puts it under test.Titles and hints. Every native tool now sets
Titleand all four hints explicitly (an omitted hint publishes nothing):analyzelist_rulesget_fixesapply_fixapply_fix_allDefault-free input schemas. Microsoft.Extensions.AI emitted a JSON-schema
defaultfor every parameter with a C# default, including"default": nullon every optional string.McpHost.NativeToolSchemaOptionsstrips the keyword from every schema node with a customTransformSchemaNode. Parameter descriptions state the non-null defaults instead (apply_fix_all.dryRungained "Default: false.").Own registration loop.
WithToolsFromAssembly()has no way to pass schema options, soMcpHostnow registers each[McpServerTool]method withMcpServerTool.Create(method, null, new McpServerToolCreateOptions { Services = sp, SchemaCreateOptions = ... }).Services = spkeeps DI parameters out of the schema, as the SDK's own registration does.McpHost.ConfigureServicesis split out ofRunAsync([NoInlining]stays onRunAsync) and is the test seam.Tools/ToolDescriptions.csholds the titles and the two pinned sentences (AnalyzePreferOverAlCompile,VerifyAfterWrite). The attributes use these constants.Contract tests (
tests/ALCops.Mcp.Tests/Contracts/ToolContractTests.cs, 32 cases) build the real container with--no-proxyand serialize each tool withMcpJsonUtilities.DefaultOptions, which gives thetools/listwire shape. They check:--no-proxy, and the same five tools plus both handlers in the default mode;defaultkeyword anywhere in a schema;propertiesandrequiredequal the method's non-service parameters (throughIServiceProviderIsService) and match a literal list per tool;[McpServerTool]method is public static;analyzerequires no arguments.The old
AnalyzeToolTests.Schema_ExposesUserParameters_HidesServicesAndCancellationTokenis removed. It built the tool withoutServices, so DI parameters leaked into its schema unnoticed. The new property/required test supersedes it.Docs.
src/ALCops.Mcp/README.mdgets a native-tool table (Tool, Title, What it does, Writes files).AGENTS.mddescribes the registration loop, the annotation table, the schema transform,ToolDescriptionsand the test seam.Deviations from the issue
WithToolsFromAssembly(..., McpServerToolCreateOptions)overload exists in ModelContextProtocol 0.9.0-preview.2. The only overload takes(Assembly?, JsonSerializerOptions?), andSchemaCreateOptionscan only be passed throughMcpServerTool.Create(MethodInfo, target, options). That is why the tools are registered by an own loop. NoDelegatingMcpServerToolwrapper is needed: the SDK copies the attribute's title and hints onto bothTool.TitleandTool.Annotations.AIJsonSchemaTransformOptions.MoveDefaultKeywordToDescriptionwas rejected. It appends(Default value: null)to every nullable optional parameter and repeats defaults the descriptions already state. A customTransformSchemaNoderemoves the keyword instead.mainalready publishes an alpha, and One error envelope and a fixed error vocabulary for all native tools #41 is out as0.3.0-alpha.3. This change is not breaking for clients (it addstitleand hints and removes a keyword), so it isfeat:without!.al_*tools are untouched, as the issue scopes.Review notes
Carried as designed (raised in review, not changed):
destructiveHint: falseonapply_fix/apply_fix_all: a code fix can rewrite or remove lines, but every write is guarded (stale check, atomic move, batch rollback) and touches only the lines the fix changes; prompting on every fix would defeat the autonomous-agent use case. This is the issue's table and a maintainer decision.openWorldHint: falseeverywhere: the tools act only on the local AL project, andanalyzereaches only the localalmcpchild process. This is the issue's table.defaultkeyword is stripped, not onlynullones: the descriptions state the non-null defaults (enforced by a contract test), and one rule is simpler than two.sp =>factories: this is the same pattern as the SDK'sWithTools<T>. Each tool is created when the container first resolvesMcpServerTool, withServices = sp.NativeToolMethodsthrows at startup for a tool method that is not public static. This is kept on purpose: the contract test catches it in CI first, and leaving the tool out oftools/listsilently would be worse.RunAsync.TransformSchemaNoderemovesdefaultfrom everyJsonObjectit is handed. No guard was added for a parameter literally nameddefault: it would need@defaultin C#, and its name would only ever appear as a key of thepropertiesmap, which the SDK does not pass to the callback.openWorldHint=falsestays even though ALCops analyzers are provisioned from NuGet. That fetch is a hosted service that runs at startup whether or not any tool is called. It belongs to the server's startup, not to a tool call.Round 1 (fixed)
InputSchema_PublishesThePinnedParametersgives the exact ordered properties and therequiredset per tool, next to the reflection-based DI-leak test.[McpServerTool]methods that are not public static now fail loudly:McpHost.NativeToolMethodsthrowsInvalidOperationExceptionnaming the method.EveryToolMethodInTheAssembly_IsPublicStaticpins the convention.Metadata[0]: it usesMetadata.OfType<MethodInfo>().Single()with an explicit assertion message.Round 2 (fixed)
ToolsList_IsTheSameFiveNativeTools_WithProxyHandlers_UnderDefaultModebuilds the container without--no-proxy. No host runs andAlMcpProxyis never resolved. The test asserts the same five tools, a non-nullListToolsHandler/CallToolHandler, and the five tools inToolCollection.ParameterDescriptions_StateEveryNonNullDefaultis tighter: the default value must sit right next to the word "default" ((default 500),Default: false.,'project' (default, ...). Before, both only had to appear somewhere in the text.Round 3 (fixed)
(?<!\w)) before the value in its first alternative. Before, "(default 1500)" matched the value 500.Test plan
dotnet build --configuration Release: 0 warnings, 0 errors.dotnet test --configuration Release: 376 passed, 0 failed, 0 skipped. That is 32 new contract cases and 1 superseded test removed.defaultremoval, the 5InputSchema_HasNoDefaultKeywordAnywherecases fail.Services = sp, the 5 property/required cases andAnalyze_RequiresNoArgumentsfail.--no-proxy --alcops-analyzers offand was sentinitialize,notifications/initializedandtools/list. The reply had five tools, each withtitleand all four hints, and contained zero"default"occurrences.ToolContractTestsconfirmed in the job logs for v18.0.41.39415 and v30.0.42.60748-beta.Closes #42
🤖 Generated with Claude Code