
An MCP server can look deceptively small. A package is installed, a method receives an attribute, and ASP.NET Core maps an endpoint. That is enough for a demonstration. It is not enough for a server that is allowed to read company data, call internal services, or change business state on behalf of an AI application.
The Model Context Protocol (MCP) is the contract between an AI host and a server that provides capabilities. It standardizes how a client discovers and invokes tools, reads resources, and obtains reusable prompts. The language model may decide that a capability is relevant, but the server still owns every important guarantee: input validation, authorization, execution, failure handling, and auditability.
This makes ASP.NET Core a natural host. The official C# SDK handles the protocol, while the existing .NET application model supplies dependency injection, configuration, authentication, authorization, health checks, logging, OpenTelemetry, resilience, and deployment support. The result should be treated as an application boundary, not as a thin collection of methods exposed to a model.
This article builds that boundary from a minimal server to a production-oriented design.
The responsibilities of an MCP server
An MCP interaction involves more than a model and a method call. The main roles are:
- The host is the AI application in which the interaction runs.
- The client maintains the protocol connection to an MCP server.
- The server publishes capabilities and executes requests.
- The model selects or proposes a capability based on its name, description, and schema.
MCP servers can expose three primary capability types:
- Tools perform operations, such as searching work items or creating an incident.
- Resources expose addressable context, such as a policy document or a known schema.
- Prompts provide reusable message templates for a specific workflow.
The distinction is architectural. A tool has execution semantics and may cause side effects. A resource is primarily read as context. A prompt guides a conversation but does not grant access to data or an operation. Treating every capability as a tool creates an unnecessarily broad and difficult-to-govern interface.
The examples below focus on tools because they show the most important application-boundary concerns. The same ASP.NET Core host can also publish resources and prompts when those abstractions fit the use case better.
Creating the ASP.NET Core project
The HTTP integration is provided by the official ModelContextProtocol.AspNetCore package. A new server starts as a normal empty ASP.NET Core application:
1dotnet new web -n WorkItems.McpServer --framework net10.0
2cd WorkItems.McpServer
3dotnet add package ModelContextProtocol.AspNetCore
The package version should be pinned through the repository’s normal dependency-management mechanism for repeatable builds. Omitting the version in the command only selects the current package during initial setup.
The minimal host requires three MCP-specific steps:
AddMcpServerregisters the server infrastructure.WithHttpTransportselects Streamable HTTP.WithToolsFromAssemblydiscovers attributed tool methods in the application assembly.
1using ModelContextProtocol.Server;
2
3WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
4
5builder.Services
6 .AddMcpServer()
7 .WithHttpTransport()
8 .WithToolsFromAssembly();
9
10WebApplication app = builder.Build();
11
12app.MapMcp();
13
14app.Run();
This is a complete protocol host, but it intentionally contains no useful capability yet. The important point is that MCP participates in the same application lifetime and service container as every other ASP.NET Core component.
With the MCP C# SDK 2.0 and the 2026-07-28 protocol revision, Streamable HTTP is stateless by default. A tool request does not depend on a transport session owned by one application instance. Any required business state should be represented by explicit identifiers and stored in the appropriate application database. This model works naturally behind a load balancer and avoids accidental instance affinity.
Designing the tool contract before the implementation
A normal C# method is written for another developer. An MCP tool is also described to a model. Its public contract therefore includes more than parameter and return types:
- a stable, unambiguous name;
- a precise description of what the operation does and does not do;
- parameter descriptions with formats, units, and constraints;
- a bounded result that is useful without being excessively large; and
- side-effect semantics that match the actual implementation.
Tool descriptions influence selection, but they are not security controls. Text such as “only call for administrators” does not enforce anything. Authorization remains server-side code.
A useful design starts with a narrow business operation. The sample server will expose two read-only tools:
search_work_itemsfinds a bounded set of work items by text.get_work_itemloads one known work item by identifier.
This is preferable to a generic query_database or execute_request tool. A narrow tool constrains inputs, produces a predictable result, supports meaningful authorization, and can be observed as a stable business operation.
Keeping business logic outside the tool class
Tool methods should remain adapters. They translate an MCP call into an application operation and translate the result back into a small protocol-facing model. Data access, external HTTP calls, and business decisions belong behind injected services.
The sample uses an abstraction that can later be backed by a database or an internal API:
1public sealed record WorkItem(
2 string Id,
3 string Title,
4 string Status,
5 string Owner,
6 DateTimeOffset UpdatedAtUtc);
7
8public interface IWorkItemStore
9{
10 Task<IReadOnlyList<WorkItem>> SearchAsync(
11 string query,
12 int limit,
13 CancellationToken cancellationToken);
14
15 Task<WorkItem?> GetAsync(
16 string id,
17 CancellationToken cancellationToken);
18}
An in-memory implementation keeps the article sample self-contained. Production code can replace it without changing the MCP contract:
1public sealed class InMemoryWorkItemStore : IWorkItemStore
2{
3 private readonly IReadOnlyList<WorkItem> _items = new List<WorkItem>
4 {
5 new WorkItem(
6 "WI-1042",
7 "Add regional failover runbook",
8 "active",
9 "platform-team",
10 DateTimeOffset.Parse("2026-08-27T09:30:00Z")),
11 new WorkItem(
12 "WI-1088",
13 "Review MCP tool authorization",
14 "review",
15 "identity-team",
16 DateTimeOffset.Parse("2026-08-29T15:10:00Z")),
17 new WorkItem(
18 "WI-1091",
19 "Bound search result payloads",
20 "planned",
21 "ai-platform-team",
22 DateTimeOffset.Parse("2026-08-30T11:45:00Z"))
23 };
24
25 public Task<IReadOnlyList<WorkItem>> SearchAsync(
26 string query,
27 int limit,
28 CancellationToken cancellationToken)
29 {
30 cancellationToken.ThrowIfCancellationRequested();
31
32 WorkItem[] matches = _items
33 .Where(item =>
34 item.Id.Contains(query, StringComparison.OrdinalIgnoreCase) ||
35 item.Title.Contains(query, StringComparison.OrdinalIgnoreCase))
36 .OrderByDescending(item => item.UpdatedAtUtc)
37 .Take(limit)
38 .ToArray();
39
40 return Task.FromResult<IReadOnlyList<WorkItem>>(matches);
41 }
42
43 public Task<WorkItem?> GetAsync(
44 string id,
45 CancellationToken cancellationToken)
46 {
47 cancellationToken.ThrowIfCancellationRequested();
48
49 WorkItem? item = _items.SingleOrDefault(candidate =>
50 string.Equals(candidate.Id, id, StringComparison.OrdinalIgnoreCase));
51
52 return Task.FromResult(item);
53 }
54}
The implementation is registered with the regular ASP.NET Core dependency-injection container:
1builder.Services.AddSingleton<IWorkItemStore, InMemoryWorkItemStore>();
The server assembly does not need a separate service locator. Dependencies can be supplied to tool methods by the SDK, while protocol arguments are described as tool input.
Implementing strongly typed tools
McpServerToolTypeAttribute marks a type that contains tools. McpServerToolAttribute publishes an individual method. DescriptionAttribute supplies information used to construct the tool definition and its input schema.
Protocol-facing result types keep the response stable and avoid returning internal entities directly:
1using System.ComponentModel;
2using ModelContextProtocol.Server;
3
4public sealed record WorkItemSummary(
5 string Id,
6 string Title,
7 string Status,
8 string Owner);
9
10public sealed record SearchWorkItemsResult(
11 int Count,
12 IReadOnlyList<WorkItemSummary> Items);
13
14public sealed record GetWorkItemResult(
15 bool Found,
16 WorkItemSummary? Item,
17 string? Message);
18
19[McpServerToolType]
20public static class WorkItemTools
21{
22 [McpServerTool(Name = "search_work_items")]
23 [Description("Searches work items by identifier or title and returns at most 25 matches.")]
24 public static async Task<SearchWorkItemsResult> SearchWorkItemsAsync(
25 IWorkItemStore workItemStore,
26 [Description("Text contained in the work item identifier or title.")]
27 string query,
28 [Description("Maximum number of matches from 1 through 25.")]
29 int limit,
30 CancellationToken cancellationToken)
31 {
32 if (string.IsNullOrWhiteSpace(query))
33 {
34 throw new ArgumentException("A non-empty search query is required.", nameof(query));
35 }
36
37 if (limit is < 1 or > 25)
38 {
39 throw new ArgumentOutOfRangeException(
40 nameof(limit),
41 limit,
42 "The result limit must be between 1 and 25.");
43 }
44
45 IReadOnlyList<WorkItem> matches = await workItemStore.SearchAsync(
46 query.Trim(),
47 limit,
48 cancellationToken);
49
50 WorkItemSummary[] items = matches
51 .Select(ToSummary)
52 .ToArray();
53
54 return new SearchWorkItemsResult(items.Length, items);
55 }
56
57 [McpServerTool(Name = "get_work_item")]
58 [Description("Gets one work item by its exact identifier without modifying it.")]
59 public static async Task<GetWorkItemResult> GetWorkItemAsync(
60 IWorkItemStore workItemStore,
61 [Description("Work item identifier in the WI-1234 format.")]
62 string id,
63 CancellationToken cancellationToken)
64 {
65 if (string.IsNullOrWhiteSpace(id))
66 {
67 throw new ArgumentException("A work item identifier is required.", nameof(id));
68 }
69
70 WorkItem? item = await workItemStore.GetAsync(
71 id.Trim(),
72 cancellationToken);
73
74 if (item is null)
75 {
76 return new GetWorkItemResult(
77 false,
78 null,
79 $"No work item with identifier '{id}' was found.");
80 }
81
82 return new GetWorkItemResult(true, ToSummary(item), null);
83 }
84
85 private static WorkItemSummary ToSummary(WorkItem item)
86 {
87 return new WorkItemSummary(
88 item.Id,
89 item.Title,
90 item.Status,
91 item.Owner);
92 }
93}
The service and CancellationToken parameters are execution dependencies, not values a model needs to invent. The remaining parameters become the model-facing input. The SDK serializes the result into MCP content, preserving the structure for clients that support structured tool output.
There are several deliberate decisions in this contract:
- Tool names are explicit and remain independent of later C# method renaming.
- Search results are capped at 25 items.
- The not-found case is an expected result rather than an unhandled exception.
- Internal timestamps and other fields not required by the consumer are omitted.
- Cancellation flows from the HTTP request into the application dependency.
- The tool contains validation and mapping, while search behavior remains in the store.
This separation also makes the code testable without starting an MCP transport. Most business tests can target the application service, and focused adapter tests can invoke the tool method directly.
Tool schemas are necessary but not sufficient validation
The SDK derives a JSON Schema from the C# method signature and descriptions. That schema helps clients construct arguments and helps a model understand the expected values. It does not make the input trustworthy.
MCP arguments cross a process boundary and require the same validation as an HTTP request body. Relevant checks include:
- required values and whitespace;
- numeric ranges and maximum page sizes;
- identifier formats;
- string length and allowed character sets;
- ownership of referenced resources;
- combinations of fields that are invalid together; and
- current business rules at the moment of execution.
Validation should reject invalid intent rather than silently changing it. For example, clamping a requested limit of 100000 to 25 hides an invalid call and can make model behavior harder to diagnose. A clear bounded error lets the client or model correct the request.
Result validation matters as well. A dependency may return more data than intended, malformed links, confidential fields, or user-provided text containing hostile instructions. The tool adapter should project the response into an allow-listed output contract and enforce size limits before returning it.
Choosing HTTP or standard input/output transport
The C# SDK also supports standard input/output (STDIO). Both transports implement MCP, but they fit different operating models.
| Concern | Streamable HTTP | STDIO |
|---|---|---|
| Typical deployment | Shared or remote service | Local process launched by the host |
| ASP.NET Core middleware | Available | Not applicable |
| Authentication | HTTP authentication and authorization | Usually inherited local process trust |
| Horizontal scaling | Natural with stateless requests | One process per client connection |
| Logging | Normal server logging | Standard output is reserved for protocol traffic |
| Best fit | Enterprise services and centrally operated capabilities | Developer tools and local integrations |
ASP.NET Core is most valuable with Streamable HTTP because the complete web platform remains available. A local server can still use the generic host and STDIO transport, but it must never write logs or banners to standard output. Any extra output corrupts the JSON-RPC stream. Logging for STDIO belongs on standard error.
Transport is not only a connectivity decision. It determines the trust boundary, deployment model, failure modes, and operational ownership of the server.
Adding authentication and endpoint authorization
An HTTP MCP endpoint should not become public merely because its tools are useful. ASP.NET Core authentication can protect the protocol endpoint before any tool is discovered or called.
The following example uses JWT bearer authentication and requires a dedicated scope. Authority and audience values come from deployment configuration rather than source code:
1using Microsoft.AspNetCore.Authentication.JwtBearer;
2using ModelContextProtocol.Server;
3
4WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
5
6string authority = builder.Configuration["Authentication:Authority"]
7 ?? throw new InvalidOperationException("Authentication authority is missing.");
8string audience = builder.Configuration["Authentication:Audience"]
9 ?? throw new InvalidOperationException("Authentication audience is missing.");
10
11builder.Services
12 .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
13 .AddJwtBearer(options =>
14 {
15 options.Authority = authority;
16 options.Audience = audience;
17 });
18
19builder.Services.AddAuthorizationBuilder()
20 .AddPolicy("mcp", policy =>
21 {
22 policy.RequireAuthenticatedUser();
23 policy.RequireClaim("scope", "mcp.tools");
24 });
25
26builder.Services.AddSingleton<IWorkItemStore, InMemoryWorkItemStore>();
27
28builder.Services
29 .AddMcpServer()
30 .WithHttpTransport()
31 .WithToolsFromAssembly();
32
33WebApplication app = builder.Build();
34
35app.UseAuthentication();
36app.UseAuthorization();
37
38app.MapMcp()
39 .RequireAuthorization("mcp");
40
41app.Run();
This code is the enforcement baseline for an environment in which clients and token issuance are already coordinated. It validates the issuer and audience through ASP.NET Core and applies a policy to the MCP endpoint. A public or generally interoperable remote MCP server needs the discovery behavior defined by the MCP authorization specification as well.
Supporting MCP authorization discovery
In the standardized flow, the MCP server is an OAuth resource server. It does not need to implement the authorization server itself, but it must tell clients where authorization is available and which resource a token is meant for.
For a protected HTTP server, that contract includes:
- OAuth 2.0 Protected Resource Metadata at the well-known metadata location;
- one or more authorization-server identifiers in that metadata;
- OAuth or OpenID Connect metadata discovery at the authorization server;
- a
401 Unauthorizedresponse with aWWW-Authenticate: Bearerchallenge and theresource_metadataURL when credentials are missing or invalid; - a canonical MCP server URI used as the OAuth resource indicator and token audience; and
- a
403 Forbiddenchallenge witherror="insufficient_scope"and the minimum required scopes when a valid token lacks permission.
The client includes the canonical MCP server URI in the resource parameter during authorization and token requests. The server then accepts only tokens issued for that resource. Checking the signature and issuer without checking the audience is insufficient because a valid token for another API must not become valid for the MCP server.
ASP.NET Core authentication remains responsible for validating the resulting access token. Protected Resource Metadata and standards-compliant challenges make that enforcement discoverable to an MCP client. Depending on the identity platform, these pieces can come from framework integration, an authorization package, or small dedicated endpoints and challenge handlers. They should not be approximated by returning authorization instructions as tool text because authorization occurs before a protected tool call can run.
An MCP server must also avoid token passthrough. The access token presented by the MCP client is intended for the MCP server. It must not be forwarded unchanged to a downstream API. Downstream access needs a separate credential or a defined token-exchange or delegation flow with the correct downstream audience. This preserves trust boundaries, rate limits, and an attributable audit trail.
Scopes should be narrow and progressive. A basic read capability does not need an administrative wildcard scope. If a later operation requires elevation, the server can challenge for the scopes required by that operation, allowing a compatible client to perform step-up authorization without granting broad access at initial connection.
Endpoint authorization answers only the broad question: may this identity connect to this MCP server? Each tool must still enforce the narrower question: may this identity perform this operation on this particular resource?
A caller with permission to search work items in tenant A must not gain access to tenant B by passing another identifier. The data-access operation needs the authenticated actor and tenant context, and its query must apply that boundary. Filtering the result after an unrestricted query is too late and creates unnecessary exposure.
The same rule applies to write tools. Permission to call update_work_item is not permission to update every work item or every field. Resource-level authorization belongs next to the business operation and must run on every invocation, including retries and continuation requests.
Treating model-controlled input as untrusted input
An MCP server receives arguments selected by a model from natural-language context. That does not make the arguments malicious by default, but it does make them untrusted. The original conversation may contain prompt injection, copied external text, stale assumptions, or ambiguous intent.
Several tool categories need particular care:
File access
Paths should resolve below an allow-listed root after normalization. Relative path segments, symbolic links, alternate data streams, and platform-specific path rules need explicit handling. A generic file tool with unrestricted paths effectively grants the host the server process identity’s filesystem permissions.
Outbound HTTP access
A URL supplied by a model can create a server-side request forgery vulnerability. Prefer named downstream clients with fixed base addresses. If dynamic destinations are unavoidable, validate scheme, host, resolved address, redirects, ports, and private-network ranges. Egress restrictions remain valuable even when application validation exists.
Database access
Models should not produce SQL for direct execution. Parameterized, purpose-specific queries behind narrow application services provide a much smaller attack surface. A read-only database credential is still not a substitute for tenant filtering and bounded queries.
Commands and scripts
A generic shell tool combines arbitrary code execution with the server’s operating-system identity. Production servers should expose specific operations with structured arguments instead. When command execution is the actual product requirement, isolation, an allow list, resource limits, a disposable environment, and a deliberate approval model are required.
Returned content
Tool output may itself contain instructions embedded in documents, tickets, web pages, or source code. The server should label and structure retrieved content accurately rather than presenting external text as trusted policy. Clients and hosts still need their own prompt-injection defenses because MCP cannot determine whether arbitrary business content is semantically trustworthy.
Designing write tools deliberately
Read-only tools are the best starting point because they reveal contract and access-control problems without changing state. Write tools add several requirements:
- The name and description must make the side effect explicit.
- Inputs should identify the target and intended change precisely.
- Authorization must be resource-aware.
- Repeated calls need defined idempotency behavior.
- Concurrency conflicts must be visible rather than overwritten silently.
- The result should describe what changed using stable identifiers.
- Sensitive or high-impact actions may require approval or elicitation.
- An audit event may be required independently of diagnostic logs.
An operation such as set_work_item_status is easier to reason about than update_work_item. It can accept an identifier, the intended status, an expected version, and an idempotency key. The server can then reject stale writes and safely identify retries.
Approval in the AI host does not replace server authorization. Conversely, server authorization does not prove that a person intended a particular destructive action. High-impact workflows often need both controls.
MCP SDK 2.0 supports Multi Round-Trip Requests for interactions that require confirmation, elicitation, sampling, or other client input while retaining stateless HTTP. Continuation state must be treated as untrusted: it needs integrity protection or a reference to shared server-side state, a short expiry, binding to the authenticated actor and operation, and replay protection. Authorization must run again when the operation continues.
Handling failures without leaking internals
MCP runs JSON-RPC over its transport, so tool failures are not ordinary REST Problem Details responses. The server should still classify errors consistently.
Four categories cover most operations:
- Input errors explain which argument is invalid and how it can be corrected.
- Expected domain outcomes represent states such as not found, already completed, or version conflict.
- Dependency failures indicate that a downstream system is unavailable or timed out.
- Unexpected failures return a generic safe message while retaining full diagnostics server-side.
Raw exception messages are poor public contracts. They can reveal connection details, table names, filesystem paths, tokens, downstream response bodies, or implementation types. Tool results should use stable domain codes when a client may need to branch on the outcome, while logs retain the exception and trace context.
Timeout and cancellation behavior also needs a clear distinction. Request cancellation should stop downstream work where possible. A server-side timeout should produce a bounded dependency failure. Neither case should be logged as an unexpected application defect unless the context demonstrates one.
Observability through the ASP.NET Core pipeline
An MCP server should fit the same observability architecture as another ASP.NET Core service. OpenTelemetry traces can connect the inbound request to database and HTTP dependencies, metrics can describe rates and latency, and structured logs can explain failures.
Useful low-cardinality dimensions include:
- MCP method;
- tool name;
- success, domain failure, validation failure, or dependency failure;
- bounded dependency name; and
- deployment region or service version.
Full arguments, prompts, access tokens, document contents, and unrestricted identifiers should not be logged by default. Tool input frequently contains personal, confidential, or high-cardinality data. A tool name is usually a useful metric dimension; a work item identifier usually is not.
Diagnostic logging and auditing remain separate concerns. A log explains why a call failed during operation. An audit record establishes that an actor performed or attempted a relevant business action. Audit delivery, schema, retention, and access control should follow the business requirement rather than the telemetry pipeline’s sampling and retention policy.
Testing the server in layers
Protocol conformance is important, but it is not the only useful test surface. A maintainable test strategy has several layers.
Business logic tests
Application services should be tested without MCP. These tests cover authorization, tenant isolation, validation, data access, concurrency, and idempotency at the layer that owns those decisions.
Tool adapter tests
Tool methods can be invoked directly with a controlled dependency. This verifies mapping, bounds, cancellation, and expected result models without network overhead:
1using Xunit;
2
3public sealed class WorkItemToolsTests
4{
5 [Fact]
6 public async Task WorkItemTools_SearchWorkItemsAsync_ReturnsBoundedMatchingItems()
7 {
8 IWorkItemStore store = new InMemoryWorkItemStore();
9 CancellationToken cancellationToken = TestContext.Current.CancellationToken;
10
11 SearchWorkItemsResult result = await WorkItemTools.SearchWorkItemsAsync(
12 store,
13 "MCP",
14 10,
15 cancellationToken);
16
17 WorkItemSummary item = Assert.Single(result.Items);
18 Assert.Equal("WI-1088", item.Id);
19 Assert.Equal(1, result.Count);
20 }
21
22 [Theory]
23 [InlineData(0)]
24 [InlineData(26)]
25 public async Task WorkItemTools_SearchWorkItemsAsync_RejectsInvalidLimits(int limit)
26 {
27 IWorkItemStore store = new InMemoryWorkItemStore();
28 CancellationToken cancellationToken = TestContext.Current.CancellationToken;
29
30 await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
31 WorkItemTools.SearchWorkItemsAsync(
32 store,
33 "MCP",
34 limit,
35 cancellationToken));
36 }
37}
Protocol integration tests
Integration tests should start the ASP.NET Core application and connect with a real MCP client. They verify that initialization, tool discovery, generated schemas, structured results, errors, authentication, and transport behavior agree with the SDK contract.
Schema assertions are particularly valuable. An accidental parameter rename or a lost DescriptionAttribute can compile cleanly while changing what the model sees. A snapshot or approval test for the published tool definitions catches that class of regression.
Authorization and adversarial tests
Security tests should exercise missing tokens, wrong audiences, insufficient scopes, cross-tenant identifiers, oversized arguments, malformed paths, blocked outbound destinations, and repeated write requests. Prompt-injection test cases should confirm that untrusted content cannot widen the server’s permissions or select an unrestricted implementation path.
Multi-instance tests
A stateless server should be tested as a stateless server. Related calls can be routed to different application instances against the same durable dependencies. If a workflow only succeeds with sticky routing, application state is still hidden in one process even when the MCP transport is configured as stateless.
Health checks and production deployment
The server can expose health endpoints independently of MCP:
1builder.Services.AddHealthChecks();
2
3WebApplication app = builder.Build();
4
5app.MapHealthChecks("/health");
6app.MapMcp()
7 .RequireAuthorization("mcp");
8
9app.Run();
Liveness should indicate whether the process can continue running. Readiness should indicate whether the instance can accept MCP traffic. Expensive downstream checks on every probe can create their own outage, so readiness dependencies and probe frequency need deliberate limits.
The application should run as a non-administrative identity with the smallest required filesystem, network, database, and cloud permissions. Secrets belong in the platform’s secret store. Downstream HttpClient instances need explicit timeouts and resilience policies. Request and response sizes need bounds at both proxy and application layers.
For stateless Streamable HTTP, ordinary horizontal scaling is appropriate. Shared business state belongs in durable services, not static dictionaries, process memory, or one pod’s local disk. A reverse proxy must preserve required MCP headers and allow the response behavior used by Streamable HTTP. Idle timeouts need to account for legitimately long-running calls without permitting unbounded execution.
Versioning requires more care than placing a version in a URL. Models and clients observe tool names, descriptions, schemas, and behavior. Removing a tool, renaming a parameter, narrowing an accepted value, or changing the meaning of a result can be a breaking contract change. Additive evolution, capability negotiation, compatibility tests, and a deprecation period are safer than replacing the interface in place.
Common implementation mistakes
Several patterns repeatedly turn a small MCP server into a risky service.
Publishing existing service methods automatically
An internal method signature reflects implementation needs, not necessarily a stable model-facing contract. Publishing it directly can expose technical parameters, internal entities, and overly broad permissions. A small adapter is intentional boundary code, not redundant boilerplate.
Returning unlimited data
Large database results become large tool payloads and consume model context. Every list tool needs a hard server-side limit, a useful summary shape, and a pagination or follow-up strategy when complete retrieval is legitimate.
Trusting the description as policy
A description helps a model select a tool. It cannot enforce authentication, authorization, approval, tenant isolation, or data classification.
Hiding business state in the server process
In-memory state makes a demo convenient but couples a workflow to one instance and disappears during restarts. Production state needs an explicit identifier, ownership rules, expiry, and durable storage when continuity matters.
Exposing generic infrastructure primitives
Generic SQL, shell, filesystem, and HTTP tools transfer too much authority to model-generated input. Purpose-specific capabilities are easier to validate, authorize, observe, and explain.
Logging complete requests and responses
Verbose protocol logging can copy prompts, credentials, personal data, and proprietary documents into a telemetry system. Structured metadata and explicit safe fields provide useful diagnostics with a much smaller privacy and security cost.
A practical production checklist
Before an MCP server receives production traffic, the following properties should be demonstrable:
- Every tool has one narrow purpose and a stable name.
- Descriptions state behavior, constraints, and side effects accurately.
- Inputs are validated independently of the generated schema.
- Results are projected into bounded, allow-listed contracts.
- Authentication protects the MCP endpoint.
- Protected Resource Metadata and authorization challenges support remote client discovery.
- Tokens are audience-bound to the MCP server and are never passed through to downstream APIs.
- Authorization is checked for the specific action and resource.
- Database, filesystem, network, and process permissions follow least privilege.
- Write tools define approval, idempotency, concurrency, and audit behavior.
- Timeouts and cancellation reach downstream operations.
- Expected failures are distinct from unexpected exceptions.
- Telemetry avoids secrets, prompts, full arguments, and unbounded identifiers.
- Tests cover published schemas, protocol calls, authorization, and hostile inputs.
- Multi-instance tests prove that no hidden affinity is required.
- Health, deployment, rollback, and tool-versioning behavior are documented.
The checklist is intentionally close to the review of an HTTP API. MCP introduces a model-facing discovery and invocation layer, but it does not suspend the engineering rules required at a service boundary.
Conclusion
Building an MCP server with ASP.NET Core is technically straightforward. Building one that deserves access to production systems requires more than protocol registration.
The official C# SDK should own MCP framing, capability discovery, schemas, and transport behavior. ASP.NET Core should own hosting, dependency injection, authentication, authorization, observability, health, and deployment. Application services should own business rules and data access. Tool classes should remain narrow adapters between those layers.
That separation creates an interface a model can understand without granting it accidental infrastructure authority. It also creates a service that can be tested, scaled, monitored, and reviewed with familiar .NET practices.
The most important design decision is therefore not the transport or the attribute on a method. It is the authority represented by each published capability. Once that authority is narrow, explicit, validated, and observable, MCP becomes a clean integration protocol rather than a shortcut around the application’s existing boundaries.
Further implementation details are available in the official MCP C# SDK documentation , the MCP server building guide , and the Model Context Protocol security best practices . The SDK 2.0 transport and migration model is covered separately in MCP C# SDK 2.0: Stateless HTTP, Interactive Tools and a Practical Migration Path .
Related articles

Aug 24, 2026 - 18 min read
Telemetry, Logging, and Auditing in .NET: Designing the Right Data Path
Telemetry, logging and auditing are often implemented through the same API because all three produce timestamped records. That similarity is …

Aug 17, 2026 - 17 min read
Automatic TTL in Azure Cosmos DB with .NET
Temporary data is easy to create and surprisingly difficult to remove reliably. Sessions, idempotency keys, import buffers, transient …

Aug 10, 2026 - 12 min read
Custom Password Hashing in ASP.NET Core: A Versioned, Upgradeable Design
Applications that do not use ASP.NET Core Identity still need a disciplined password-storage design. This often leads to a requirement …
Let's Work Together
Looking for an experienced Platform Architect or Engineer for your next project? Whether it's cloud migration, platform modernization or building new solutions from scratch - I'm here to help you succeed.
