Large Language ModelsGenerate imagesGenerate videos

How to Build an MCP Server in C# and Java: Tools, Transports, and Testing

Build the same MCP server twice, in C# with the official NuGet package and in Java with Spring AI. Add a word counter and an image tool that calls the PicassoIA API, choose stdio or HTTP, then test with the MCP Inspector and a real AI client.

How to Build an MCP Server in C# and Java: Tools, Transports, and Testing
Cristian Da Conceicao
Founder of Picasso IA

An MCP server is a small program that waits for a JSON-RPC message, runs a function, and sends the result back to an AI client. That is the whole trick. It only feels heavy in C# and Java because both ecosystems come with project templates, dependency injection, annotations, and build tools. This tutorial cuts through that. You will build the same two tools twice, once in C# with the official ModelContextProtocol NuGet package and once in Java with Spring AI's MCP starter, then test both with the MCP Inspector and connect them to a real client.

The two tools are deliberately small. word_count proves the plumbing works, and generate_image calls the PicassoIA API, so an AI client such as Claude can ask your code for a picture and get a link back. The code follows the official SDK documentation, but pin your own package versions and run the checks in the testing section before you trust any of it.

What an MCP Server Really Does

The Model Context Protocol is an open standard that lets an AI application (the client) talk to external programs (servers) in one consistent way. The client connects, asks the server what it can do, and then calls those capabilities when the model decides it needs them. Your server never talks to the model directly. It answers requests, and the client handles the conversation.

Hand drawing arrows between three boxes on a whiteboard to sketch an MCP server architecture

Tools, Resources, and Prompts

A server can offer three kinds of capability:

  • Tools are functions the model can call, such as generate_image or word_count. Each one has a name, a description, and a JSON schema for its inputs.
  • Resources are read-only data addressed by a URI, like a file, a database row, or a log.
  • Prompts are reusable message templates the user can pick from a menu.

Most servers ship only tools, and tools are what this tutorial builds. The description you write for each tool matters more than the code inside it, because the model reads that text to decide when to call it.

💡 Tip: Write tool descriptions the way you would brief a new colleague. Say what the tool does, what it needs, and what it returns.

One Protocol, Two Stacks

Both SDKs hide the JSON-RPC details. You declare a method, describe its parameters, and the library builds the schema and handles the request cycle. Here is how the two stacks line up:

AspectC#Java
Main packageModelContextProtocolio.modelcontextprotocol.sdk:mcp or the Spring AI starters
Declare a tool[McpServerTool] attribute@Tool annotation (Spring AI)
Stdio transportWithStdioServerTransport()spring-ai-starter-mcp-server
HTTP transportModelContextProtocol.AspNetCorespring-ai-starter-mcp-server-webmvc or -webflux
Runtime.NET SDKJava 17 or newer

The C# SDK is maintained in collaboration with Microsoft, and the Java SDK's Spring integrations now live in Spring AI. In both cases you work with annotations and dependency injection, so the code reads like any other service in that language.

Set Up .NET and Java

Install what you need once and both builds will run on the same laptop. You also need Node.js, because the MCP Inspector runs through npx.

Overhead view of a desk with a laptop, a notebook diagram, coffee and a USB-C cable

.NET Requirements

Install the .NET SDK and confirm it with dotnet --version. Check the ModelContextProtocol package page on NuGet for the minimum framework it targets, then pick an SDK that satisfies it. A code editor helps, though a plain terminal is enough for this project.

Java Requirements

Install JDK 17 or newer, plus Maven or Gradle. The Java SDK lists Java 17 as its baseline. This tutorial uses Maven and a Spring Boot project, because the Spring AI starter removes most of the transport code you would otherwise write by hand.

Both servers read the PicassoIA credential from an environment variable. Create an API token at picassoia.com/en/api (it starts with pia_sk_) and export it as PICASSOIA_API_TOKEN. Never paste it into source code, and check the PicassoIA pricing page to see which plans include API access.

Build the C# Server

Create the Project

Run these commands in an empty folder:

dotnet new console -n PicassoMcp
cd PicassoMcp
dotnet add package ModelContextProtocol
dotnet add package Microsoft.Extensions.Hosting

Then replace the contents of Program.cs:

using System.Net.Http.Headers;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var builder = Host.CreateApplicationBuilder(args);

// Standard output carries the protocol, so every log line goes to standard error.
builder.Logging.AddConsole(options =>
{
    options.LogToStandardErrorThreshold = LogLevel.Trace;
});

builder.Services.AddSingleton(_ =>
{
    var client = new HttpClient { BaseAddress = new Uri("https://api.picassoia.com/v1/") };
    var token = Environment.GetEnvironmentVariable("PICASSOIA_API_TOKEN");
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
    return client;
});

builder.Services
    .AddMcpServer()
    .WithStdioServerTransport()
    .WithToolsFromAssembly();

await builder.Build().RunAsync();

Two details deserve attention. The logger sends everything to standard error, because standard output belongs to the protocol. And WithToolsFromAssembly() scans your project for classes marked as tool types, so you never register tools by hand.

Add Your First Tool

Create PicassoTools.cs next to Program.cs. Start with the simple tool:

using System.ComponentModel;
using ModelContextProtocol.Server;

[McpServerToolType]
public static class PicassoTools
{
    [McpServerTool(Name = "word_count")]
    [Description("Counts the words in a piece of text and returns the number.")]
    public static int WordCount([Description("The text to count")] string text) =>
        // A null separator splits on any whitespace.
        text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
}

The three attributes do all the work. [McpServerToolType] marks the class, [McpServerTool] marks the method, and each [Description] becomes text the model reads. Parameter names and types turn into the input schema automatically.

Developer's fingers resting on a laptop while writing C# server code

Call the PicassoIA API

The PicassoIA API is asynchronous and follows the Replicate pattern. You create a prediction with POST /v1/models/{owner}/{name}/predictions, then poll GET /v1/predictions/{id} until the status reads succeeded. Authentication is a bearer token, and one account can run 5 predictions at the same time. Add this method inside the same class, with these extra using lines at the top of the file:

using System.Net.Http.Json;
using System.Text.Json;
using ModelContextProtocol;
[McpServerTool(Name = "generate_image")]
[Description("Generates an image with PicassoIA and returns the finished prediction as JSON.")]
public static async Task<string> GenerateImage(
    HttpClient client,
    [Description("What the image should show")] string prompt,
    [Description("Aspect ratio such as 16:9 or 1:1")] string aspectRatio = "16:9",
    CancellationToken cancellationToken = default)
{
    var created = await client.PostAsJsonAsync(
        "models/picassoia/picassoia-image/predictions",
        new { input = new { prompt, aspect_ratio = aspectRatio } },
        cancellationToken);
    created.EnsureSuccessStatusCode();

    using var createdDoc = JsonDocument.Parse(
        await created.Content.ReadAsStringAsync(cancellationToken));
    var id = createdDoc.RootElement.GetProperty("id").GetString();

    while (true)
    {
        await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);

        var json = await client.GetStringAsync($"predictions/{id}", cancellationToken);
        using var doc = JsonDocument.Parse(json);
        var status = doc.RootElement.GetProperty("status").GetString();

        if (status == "succeeded") return json;
        if (status is "failed" or "canceled")
            throw new McpException($"The generation ended with status '{status}'.");
    }
}

The HttpClient parameter is resolved from dependency injection, so the token you configured in Program.cs travels with it. Because the method accepts a CancellationToken, the client can abort a slow generation instead of leaving it hanging. The model behind the call is PicassoIA Image, and the same pattern works for the other models the API exposes.

Build the Java Server

The Java version has the same two tools and the same behavior. Only the packaging changes.

Bearded developer working at a standing desk beside a tall window on a Java project

Add the Spring AI Starter

Create a Spring Boot project and import the Spring AI bill of materials, then add the stdio starter:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-bom</artifactId>
      <version>${spring-ai.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-mcp-server</artifactId>
  </dependency>
</dependencies>

Set spring-ai.version to the release you want. Then add src/main/resources/application.properties:

spring.main.web-application-type=none
spring.main.banner-mode=off
logging.pattern.console=
spring.ai.mcp.server.name=picassoia-java
spring.ai.mcp.server.version=1.0.0

The first three lines matter as much as the dependency. They switch off the web server, the startup banner, and the console log pattern, because anything printed to standard output breaks the stdio stream.

Write the Tool Class

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;

@Service
public class PicassoTools {

    private final RestClient client;

    public PicassoTools(RestClient.Builder builder,
                        @Value("${PICASSOIA_API_TOKEN}") String token) {
        this.client = builder
                .baseUrl("https://api.picassoia.com/v1")
                .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token)
                .build();
    }

    @Tool(description = "Counts the words in a piece of text and returns the number.")
    public int wordCount(@ToolParam(description = "The text to count") String text) {
        return text.isBlank() ? 0 : text.trim().split("\\s+").length;
    }

    @Tool(description = "Generates an image with PicassoIA and returns the finished prediction.")
    public Map<String, Object> generateImage(
            @ToolParam(description = "What the image should show") String prompt,
            @ToolParam(description = "Aspect ratio such as 16:9 or 1:1", required = false)
            String aspectRatio) throws InterruptedException {

        String ratio = aspectRatio == null ? "16:9" : aspectRatio;
        Map<String, Object> created = client.post()
                .uri("/models/picassoia/picassoia-image/predictions")
                .contentType(MediaType.APPLICATION_JSON)
                .body(Map.of("input", Map.of("prompt", prompt, "aspect_ratio", ratio)))
                .retrieve()
                .body(new ParameterizedTypeReference<>() {});

        String id = (String) created.get("id");
        for (int attempt = 0; attempt < 60; attempt++) {
            Thread.sleep(5000);
            Map<String, Object> prediction = client.get()
                    .uri("/predictions/{id}", id)
                    .retrieve()
                    .body(new ParameterizedTypeReference<>() {});
            String status = (String) prediction.get("status");
            if ("succeeded".equals(status)) return prediction;
            if ("failed".equals(status) || "canceled".equals(status)) {
                throw new IllegalStateException("The generation ended with status " + status);
            }
        }
        throw new IllegalStateException("The generation took longer than five minutes.");
    }
}

@Tool and @ToolParam play the same role as the C# attributes. Spring turns them into the tool name, the description, and the input schema. The method returns a Map, which Spring AI serializes to JSON for the client.

Annotated methods are not exposed until you register them. Add one bean to your main class:

@SpringBootApplication
public class PicassoMcpApplication {

    public static void main(String[] args) {
        SpringApplication.run(PicassoMcpApplication.class, args);
    }

    @Bean
    ToolCallbackProvider picassoTools(PicassoTools tools) {
        return MethodToolCallbackProvider.builder().toolObjects(tools).build();
    }
}

Build it with mvn package and you get a runnable JAR.

Use the Plain Java SDK

If your project has no Spring, depend on io.modelcontextprotocol.sdk:mcp and import mcp-bom to keep the module versions aligned. The server itself is a few lines:

StdioServerTransportProvider transport =
        new StdioServerTransportProvider(McpJsonDefaults.getMapper());

McpSyncServer server = McpServer.sync(transport)
        .serverInfo("picassoia-java", "1.0.0")
        .capabilities(ServerCapabilities.builder().tools(true).build())
        .build();

That snippet follows the 2.0 line of the SDK. You add each tool as a SyncToolSpecification with a name, a description, a JSON schema, and a call handler. Builder details such as the transport constructor differ between releases, so copy the tool snippet from the README that matches your version. Spring users get all of this for free, which is why the starter is the shorter path.

Two engineers pairing at a long table and pointing at the same monitor

Stdio or HTTP Transport

The transport decides who launches your server and who can reach it.

TransportBest forC#Java
StdioLocal tools launched by the clientWithStdioServerTransport()spring-ai-starter-mcp-server
HTTPRemote or shared serversWithHttpTransport() and MapMcp()WebMVC or WebFlux starter

With stdio, the client starts your process and talks to it over standard input and output. It is the simplest option and nothing is exposed to the network, so start there. With HTTP, one deployment serves many clients, which means you now own authentication, TLS, and rate limits.

In C#, switch to HTTP by adding the ModelContextProtocol.AspNetCore package and changing the host:

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddMcpServer()
    .WithHttpTransport()
    .WithToolsFromAssembly();

var app = builder.Build();
app.MapMcp();
app.Run();

In Java, swap the starter for spring-ai-starter-mcp-server-webmvc, or the WebFlux variant for reactive apps. The Spring AI documentation lists /sse and /mcp/message as the default endpoints and spring.ai.mcp.server.stdio=true for running stdio alongside HTTP.

⚠️ Warning: An HTTP server whose tool calls a paid or rate-limited API needs authentication before it goes anywhere near the internet. Anyone who can reach the endpoint can call the tool.

Ethernet patch cables plugged into a metal patch panel in an equipment rack

Test and Debug Your Server

Run the MCP Inspector

The Inspector is a browser tool that connects to your server, lists its tools, and lets you call them with hand-typed arguments. Build first, then launch it with your server command:

dotnet build
npx @modelcontextprotocol/inspector dotnet run --project ./PicassoMcp --no-build

npx @modelcontextprotocol/inspector java -jar target/picassoia-mcp-0.0.1.jar

Connect, open the Tools tab, and list the tools. You should see word_count and generate_image, each with the descriptions you wrote. Call word_count with one two three and expect 3. Then call generate_image with a short prompt and wait for the prediction JSON to come back.

Rubber duck beside an open laptop on a walnut desk during a late debugging session

Connect a Real Client

With Claude Code, one command registers the C# server:

claude mcp add picassoia-dotnet -e PICASSOIA_API_TOKEN=pia_sk_your_token -- dotnet run --project ./PicassoMcp --no-build

Claude Desktop reads a JSON file instead. Add both servers to claude_desktop_config.json:

{
  "mcpServers": {
    "picassoia-dotnet": {
      "command": "dotnet",
      "args": ["run", "--project", "C:/work/PicassoMcp", "--no-build"],
      "env": { "PICASSOIA_API_TOKEN": "pia_sk_your_token" }
    },
    "picassoia-java": {
      "command": "java",
      "args": ["-jar", "C:/work/picassoia-mcp/target/picassoia-mcp-0.0.1.jar"],
      "env": { "PICASSOIA_API_TOKEN": "pia_sk_your_token" }
    }
  }
}

Restart the client and ask it to "generate a 16:9 photo of a lighthouse at sunset". It should call your tool and return the prediction with the image link.

Five Mistakes That Break Servers

  1. Printing to standard output. A stray Console.WriteLine or System.out.println corrupts the JSON-RPC stream. Log to standard error only.
  2. Vague descriptions. If two tools sound alike, the model picks the wrong one. Name the input, the output, and the side effects.
  3. Polling without a limit. The C# loop stops on cancellation and the Java loop stops after 60 attempts. Pick a limit that fits your slowest model.
  4. Hard-coded secrets. Read tokens from the environment, as both examples do, and never commit them.
  5. Huge return values. Send back a URL or a short summary, not megabytes of data the model has to read.

Remember the concurrency ceiling too. With 5 predictions allowed at once per account, a chatty client that fires many generate_image calls will start to see errors, so queue the calls or return a clear message.

How to Use Sonnet 5 on PicassoIA

Writing boilerplate is where an AI model saves the most time here, and Claude Sonnet 5 on PicassoIA is built for coding and multi-step tool-use tasks. Use it to draft new tools, then verify every API name against the SDK documentation.

Woman reading on a tablet in a cafe window seat with a notebook and coffee

  1. Open the model page. Go to the Claude Sonnet 5 page on PicassoIA and find the prompt box.
  2. Describe one tool. Name the language, the SDK, and the behavior: "Write a C# MCP tool using the ModelContextProtocol package that converts a temperature between Celsius and Fahrenheit."
  3. Set a system prompt once. Something like "Never write to standard output. Use [McpServerTool] and [Description] attributes" keeps every answer consistent.
  4. Choose the effort level. Leave it on low for boilerplate. Raise it to high or max when the code touches several files or a bug is stubborn.
  5. Attach a screenshot when something fails. The model reads images, so a screenshot of an Inspector error works as well as pasted text.
  6. Test the result. Paste the code into your project and run it through the Inspector before you trust it.
SettingDefaultChange it when
effortlowThe task needs deeper reasoning
max_tokens8192The answer gets cut off
system_promptemptyYou want a fixed coding style or role
imagenoneYou want to share a screenshot or a diagram

If you want a second opinion on the same prompt, compare the output from Claude Fable 5, GPT 5.6 Sol, or Kimi K2.6.

Try Image Generation on Picasso IA

You now have two working servers that hand image generation to an AI client. The next step is to see what the model behind them can do on its own. Open PicassoIA Image, type the same prompt you sent through your tool, and compare the result. Then change the aspect ratio, rewrite the lighting, and try three variations of one scene. When a render is close but not right, send it to PicassoIA Image Editor Pro and fix the details instead of starting over.

The more you experiment with prompts on Picasso IA, the better your tool descriptions and default arguments will get. Create your first image today, then wire the best prompt into your server and let your AI client do the rest.

Hands holding a printed photograph of a misty pine forest in front of a studio window

Share this article