The Model Context Protocol (MCP) is an open standard that defines how AI clients (such as Claude Desktop, VS Code, or custom chat UIs) access external data and services. It enables large language models to discover and invoke tools, read resources, and use prompt templates — all through a well-defined, transport-agnostic protocol.
In this post, we'll walk through the best practices for building MCP servers that integrate with Autodesk Platform Services (APS), drawing from patterns used in real-world reference implementations across JavaScript, Python, and .NET.
Setup
Official MCP SDK
The official MCP SDK is the recommended starting point for all languages:
| Language | Package | Example |
|---|---|---|
| JavaScript/TypeScript | @modelcontextprotocol/sdk |
aps-mcp-app-example |
| C# / .NET | ModelContextProtocol |
aps-aecdm-mcp-dotnet |
| Python | fastmcp (built on the official SDK) |
aps-mcp-server-python |
The official SDK provides the core protocol implementation: message framing, transport abstractions, tool/resource registration hooks, and session management. It handles the protocol plumbing so you can focus on your domain logic.
JavaScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const server = new McpServer({
name: "My APS MCP Server",
version: "1.0.0",
});.NET
var builder = Host.CreateEmptyApplicationBuilder(settings: null);
builder.Services.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly();
var app = builder.Build();
await app.RunAsync();Python
For Python, FastMCP provides a higher-level convenience layer on top of the official SDK. It reduces boilerplate significantly: a decorator-based API lets you register tools, resources, and prompts in just a few lines:
from fastmcp import FastMCP
mcp = FastMCP(
"My APS MCP Server",
)FastMCP handles server initialization, tool schema generation from type hints, transport setup, and more, making it a solid choice when rapid iteration matters.
Stateless vs. Stateful Servers
An MCP server can be either stateless or stateful, and the choice impacts how you design your architecture.
Stateless
A stateless server creates a fresh McpServer instance for every incoming request. There is no session continuity between requests, no shared memory, no accumulated context. This is the simplest model and works well for:
- Remote, multi-tenant deployments where each request is independent
- Servers that don't need to track user sessions
- Horizontally scalable services behind a load balancer
The aps-mcp-app-example (JavaScript) demonstrates this pattern: each incoming HTTP request creates a new McpServer, connects it to a transport, handles the request, and then tears everything down:
app.all("/mcp", async (req, res) => {
const server = createMcpServer(options);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // No sessions
});
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});Setting sessionIdGenerator: undefined explicitly signals that the server is stateless.
Stateful
A stateful server maintains session state across requests. This is necessary when:
- You need to track per-user authentication tokens (e.g., 3-legged OAuth)
- The server accumulates context over a conversation
- You're running a local STDIO server tied to a single client
The aps-mcp-server-python 3LO example demonstrates stateful session management. It uses the MCP session context (ctx) to store and retrieve OAuth tokens between tool invocations:
@mcp.tool()
async def list_hubs(ctx: Context) -> list[dict] | dict:
token = await _get_valid_token(ctx)
if not token:
return {
"auth_required": True,
"auth_url": _build_auth_url(ctx.session_id),
"message": "Open auth_url in a browser to authenticate, then call list_hubs again."
}
return await _list_hubs(token)The aps-aecdm-mcp-dotnet (.NET) example is also stateful. It stores tokens in global state and communicates with a locally-spawned Viewer via WebSockets.
Transport
MCP defines the transport layer separately from the protocol itself. The two main transport approaches are STDIO and Streamable HTTP.
STDIO
STDIO transport communicates via standard input/output streams. The MCP client launches the server as a child process and sends/receives JSON-RPC messages through stdin and stdout.
// .NET example: STDIO transport
builder.Services.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly();STDIO is ideal for:
- Local development and testing — no networking required
- Desktop clients like Claude Desktop that spawn the server as a subprocess
- Single-user scenarios — one client per server process
Configuration for Claude Desktop typically looks like:
{
"mcpServers": {
"my-server": {
"command": "dotnet",
"args": ["run", "--project", "/path/to/mcp-server.csproj"]
}
}
}Streamable HTTP
For all other use cases, especially remote deployments, multi-user servers, and web-based clients, Streamable HTTP is recommended. It runs the MCP server as an HTTP endpoint, typically at a /mcp path.
// JavaScript example: Streamable HTTP with Express
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
# Python (FastMCP): Streamable HTTP
mcp.run(transport="streamable-http", host="0.0.0.0", port=5000)
# Or via CLI:
# uv run fastmcp run server.py --transport streamable-http --port 5000Streamable HTTP advantages:
- Works across network boundaries (clients and servers on different machines)
- Supports multiple concurrent clients
- Compatible with standard web infrastructure (load balancers, reverse proxies, CORS)
- Enables session management via HTTP headers
Authentication
Authentication for MCP servers integrating with APS involves two distinct layers. Understanding when and how to use each is critical.
Layer 1: Auth Between MCP Client and MCP Server
This layer controls who can connect to your MCP server. For STDIO-based local servers, this is implicit - only the local user running the process can connect. For HTTP-based servers, you may need to implement:
- API key or token validation on incoming requests
- OAuth-based client authentication
Layer 2: Auth Between MCP Server and APS
This is where your server authenticates with Autodesk Platform Services to call APS APIs on behalf of the application or user. APS supports three main authentication approaches, each suited to different scenarios:
2-Legged OAuth
The simplest approach. The server uses its own client ID and secret to obtain a token tied to the application identity. No user context is involved.
async with httpx.AsyncClient() as client:
response = await client.post(
APS_TOKEN_URL,
data={
"grant_type": "client_credentials",
"scope": "bucket:read data:read",
},
auth=(APS_CLIENT_ID, APS_CLIENT_SECRET),
)
data = response.json()When to use: App-owned resources, no user context needed. For example, accessing OSS buckets owned by the application.
Secure Service Accounts
An SSA is an Autodesk identity that can obtain 3-legged tokens without user interaction, making it ideal for automated workflows that require user-context APIs (like Data Management API). The server creates a signed JWT assertion and exchanges it for an access token:
payload = {
"iss": APS_CLIENT_ID,
"sub": APS_SSA_ID,
"aud": APS_TOKEN_URL,
"exp": now + 300,
"scope": ["data:read"],
}
assertion = jwt.encode(payload, SSA_PRIVATE_KEY, algorithm="RS256",
headers={"kid": SSA_KEY_ID})
// JavaScript equivalent
const assertion = jwt.sign(payload, serviceAccountPrivateKey, {
algorithm: "RS256",
header: { alg: "RS256", kid: serviceAccountKeyId },
});When to use: Automated server-to-server workflows. The service account must be pre-authorized to access the target resources.
3-Legged OAuth
The full OAuth 2.0 authorization code flow. The user explicitly signs in and consents. The server receives a code via callback and exchanges it for access + refresh tokens.
@mcp.custom_route("/callback", methods=["GET"])
async def oauth_callback(request: Request) -> HTMLResponse:
code = request.query_params.get("code")
session_id = request.query_params.get("state")
tokens = await _exchange_code(code)
_pending_tokens[session_id] = {
"access_token": tokens["access_token"],
"refresh_token": tokens.get("refresh_token"),
"expires_at": time.time() + tokens.get("expires_in", 3600),
}
return HTMLResponse("Authentication successful! You can close this window.")When to use: Acting on behalf of real users with explicit consent. Sessions must be stateful to track per-user tokens.
When to Use Which
| Approach | User Interaction | Token Type | Best For |
|---|---|---|---|
| 2-Legged (Client Credentials) | None | Application token | App-owned resources, no user context |
| Secure Service Accounts | None | User-context token | Automated workflows requiring user-context APIs |
| 3-Legged (Authorization Code) | User signs in | User token | Acting on behalf of real users with consent |
Best Practices
- Always cache tokens and reuse them until they expire. All three reference implementations use in-memory caching with expiry checks:
if _token_cache["access_token"] and time.time() < _token_cache["expires_at"] - 60:
return _token_cache["access_token"]- Refresh tokens proactively — check for expiry with a margin (e.g., 60 seconds buffer) before making API calls.
- Keep credentials out of code — use environment variables (
.envfiles) for client IDs, secrets, and SSA keys. - Request minimal scopes — only request the OAuth scopes your tools actually need (e.g.,
data:read,bucket:read).
Tools
What is an MCP Tool?
A tool is a function that the LLM can invoke to perform an action or retrieve data. It's the primary way your MCP server exposes functionality. Each tool has:
- A name and description (used by the LLM to decide when to call it)
- An input schema defining its parameters
- A callback function that executes the logic
- Optional annotations (e.g.,
readOnlyHintto signal the tool doesn't mutate state)
Implementing Tools
Python (FastMCP) — Decorator-based
FastMCP infers the tool schema from function signatures and docstrings:
@mcp.tool()
async def list_buckets() -> list[dict]:
"""List all OSS buckets owned by the configured APS application."""
token = await _get_access_token()
return await _list_oss_buckets(token)
@mcp.tool()
async def list_objects(bucket_key: str) -> list[dict]:
"""List objects stored in a specific OSS bucket.
Args:
bucket_key: The unique key identifying the OSS bucket.
"""
token = await _get_access_token()
return await _list_oss_objects(token, bucket_key)JavaScript — Factory pattern with Zod schemas
The aps-mcp-app-example uses a factory pattern where each tool is a module exporting a factory function. Input schemas are defined with Zod:
import z from "zod";
export const getProjectContentsToolFactory = ({}) => ({
name: "get-project-contents",
config: {
title: "Get project contents",
description: "Retrieves top-level folders in a project or contents of a specified folder.",
inputSchema: {
accountId: z.string().nonempty().describe("The ID of the account."),
projectId: z.string().nonempty().describe("The ID of the project."),
folderId: z.string().optional().describe("The ID of the folder."),
},
annotations: { readOnlyHint: true },
},
callback: async ({ accountId, projectId, folderId }) => {
// ... tool implementation
},
});Tools are registered dynamically in the server setup:
for (const toolFactory of Object.values(tools)) {
const { name, config, callback } = toolFactory(options);
registerAppTool(server, name, config, callback);
}.NET — Attribute-based registration
The .NET SDK uses attributes and auto-discovery. Annotate a static class with [McpServerToolType] and each tool method with [McpServerTool]:
[McpServerToolType]
public static class AECDMTools
{
[McpServerTool, Description("Get the ACC hubs from the user")]
public static async Task<string> GetHubs()
{
// ... tool implementation
}
[McpServerTool, Description("Get the ACC projects from one hub")]
public static async Task<string> GetProjects(
[Description("Hub id to query the projects from")] string hubId)
{
// ... tool implementation
}
}The server discovers all tools in the assembly automatically with WithToolsFromAssembly().
Returning content vs. structuredContent
MCP tools can return both content (human-readable text/images for the LLM) and structuredContent (machine-readable JSON for programmatic consumption). The aps-mcp-app-example consistently uses both:
callback: async ({ projectId, designId, region }) => {
// ... fetch data
return {
structuredContent: props.data, // Full JSON for MCP Apps / UI rendering
content: [{
type: "text",
text: `Found properties containing ${props.data.collection.length} elements`,
}],
};
}content: An array of content blocks (text, images, etc.) that the LLM reads and reasons about. Keep it concise and descriptive.structuredContent: A JSON object that can be consumed programmatically — for example, by an MCP App UI that renders a 3D viewer or a data table.
Use structuredContent when your tools need to pass rich data to a UI component or when clients need to process the response programmatically. Use content when the response is primarily for the LLM to interpret and relay to the user.
Additional Resources
-
Reference Implementations:
- aps-mcp-app-example — JavaScript MCP server with Streamable HTTP, SSA auth, MCP Apps support, and APS Viewer integration
- aps-aecdm-mcp-dotnet — .NET MCP server with STDIO transport, PKCE auth, and AEC Data Model API
- aps-mcp-server-python — Python MCP servers demonstrating 2LO, SSA, and 3LO authentication patterns
-
Specifications & Guides:
- MCP Specification
- MCP Auth Patterns (APS wiki) — detailed comparison of authentication approaches for APS MCP servers
- Autodesk Platform Services Documentation
- Secure Service Accounts (SSA) Guide