Connecting Autodesk APS (2-Legged)
Giving Your MCP System Access to Real Design Data
Context: What Autodesk Has Built
Autodesk has been actively investing in MCP. Here is the landscape as of 2026:
| What | Where | Status |
|---|---|---|
| Sample APS MCP server (Node.js) | github.com/autodesk-platform-services/aps-mcp-server-nodejs | Public, experimental |
| Official AEC Data Model MCP server (.NET) | github.com/autodesk-platform-services/aps-aecdm-mcp-dotnet | Public, experimental |
| Autodesk-hosted MCP servers (enterprise) | autodesk.com/solutions/autodesk-ai/autodesk-mcp-servers | Announced, coming soon |
The official Node.js server exposes tools for navigating Autodesk Forma (ex AutodeskConstruction Cloud): listing projects, browsing issues, accessing documents. It uses Secure Service Accounts (SSA) and is designed as a stdio subprocess for tools like Claude Desktop and Cursor.
That architecture doesn't fit what we've been building. Our system is HTTP-first. More importantly, Forma data (projects, issues, folders) requires 3-legged authentication. The user must log in, because Forma resources are user-scoped, not app-scoped. We cover that in Chapter 4.
In this chapter we focus on what 2-legged (app-to-service) tokens can do: OSS (Object Storage Service), the file storage layer of APS. With a 2-legged token you can create buckets, list them, and manage objects. No user login needed, and it works on the free tier.
We build a standalone APS MCP server, run it on port 3001, and connect VS Code Copilot directly to it, alongside your existing workshop server. This demonstrates MCP's one-to-many architecture: a single client (VS Code Copilot) connects to multiple independent MCP servers and sees all their tools as one unified set.
VS Code Copilot → server.js / Server.cs :3000 → Open-Meteo API
→ aps-server.js / ApsServer.cs :3001 → APS API (OSS)Set Up Credentials
Store your credentials
Your project folder should look like this before adding credentials:
devcon-workshop/
├── .vscode/
│ └── mcp.json
├── node_modules/
├── server.js
├── package.json
└── package-lock.jsondevcon-workshop/
├── .vscode/
│ └── mcp.json
├── Properties/
│ └── launchSettings.json
├── appsettings.Development.json
├── appsettings.json
├── DevconWorkshop.csproj
└── Server.csStore your APS client ID and secret. The two stacks use different mechanisms — .env for Node.js, user-secrets for .NET — but both keep credentials outside source control.
APS_CLIENT_ID="your-client-id-here"
APS_CLIENT_SECRET="your-client-secret-here"dotnet user-secrets init
dotnet user-secrets set "APS_CLIENT_ID" "your-client-id-here"
dotnet user-secrets set "APS_CLIENT_SECRET" "your-client-secret-here"These credentials were generated in Prerequisites when you created your APS application credentials.
Node.js — Never commit
.envto git. Add it to.gitignore..NET —
dotnet user-secretsstores values under your user profile (outside the project folder), so they are never accidentally checked in. They are read byIConfigurationautomatically when running in Development.
Install the credential loader (Node.js only) and the APS SDK packages:
npm install dotenv
npm install @aps_sdk/autodesk-sdkmanager @aps_sdk/authentication @aps_sdk/ossdotnet add package Autodesk.SDKManager --version 1.1.2
dotnet add package Autodesk.Authentication --version 2.0.1
dotnet add package Autodesk.Oss --version 2.3.3| Package | Stack | What it covers |
|---|---|---|
dotenv | Node.js | Loads .env into process.env |
@aps_sdk/autodesk-sdkmanager | Node.js | Shared SDK manager and auth provider utilities |
@aps_sdk/authentication | Node.js | 2-legged and 3-legged OAuth tokens |
@aps_sdk/oss | Node.js | Object Storage Service (buckets and objects) |
Autodesk.SDKManager | .NET | Shared SDK manager and DI plumbing |
Autodesk.Authentication | .NET | 2-legged and 3-legged OAuth tokens |
Autodesk.Oss | .NET | Object Storage Service (buckets and objects) |
Build the APS MCP Server
In Node.js, create a new file called aps-server.js. In .NET, add a new file called ApsServer.cs alongside the existing Server.cs.
The APS server handles authentication automatically — it fetches a 2-legged access token on demand and refreshes it when expired. It then exposes APS capabilities as MCP tools.
devcon-workshop/
├── .vscode/
│ └── mcp.json
├── node_modules/
├── .env
├── aps-server.js ← new
├── package.json
├── package-lock.json
└── server.jsdevcon-workshop/
├── .vscode/
│ └── mcp.json
├── Properties/
│ └── launchSettings.json
├── appsettings.Development.json
├── appsettings.json
├── ApsServer.cs ← new
├── DevconWorkshop.csproj
└── Server.cs.NET — Set
<StartupObject>in your csproj. Adding a secondMainmethod (inApsServer.cs) makes the compiler ambiguous about which one to run. Inside the<PropertyGroup>ofDevconWorkshop.csproj, add<StartupObject>DevconWorkshop.ApsServer</StartupObject>. The defaultdotnet runwill then start the APS server; later we'll override this flag to run the workshop server instead.
The Imports / Usings - What Each One Does
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import http from "node:http";
import "dotenv/config";
import { AuthenticationClient, Scopes } from "@aps_sdk/authentication";
import { StaticAuthenticationProvider } from "@aps_sdk/autodesk-sdkmanager";
import { OssClient } from "@aps_sdk/oss";using Autodesk.Authentication;
using Autodesk.Authentication.Model;
using Autodesk.Oss;
using Autodesk.Oss.Model;
using Autodesk.SDKManager;
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Serialization;Node.js — what each import does
| Import | What it does |
|---|---|
McpServer | Creates the APS MCP server instance |
StreamableHTTPServerTransport | Exposes it over HTTP - same pattern as every other server in this workshop |
z | Validates tool inputs |
http | Node.js built-in HTTP server on port 3001 |
dotenv/config | Loads APS_CLIENT_ID and APS_CLIENT_SECRET from your .env file |
AuthenticationClient, Scopes | SDK client for OAuth tokens; Scopes is the enum of permission scopes |
StaticAuthenticationProvider | Wraps a token string so SDK clients can use it |
OssClient | SDK client for Object Storage Service, no manual URLs needed |
.NET — what each using does
| Using | What it does |
|---|---|
Autodesk.Authentication + Autodesk.Authentication.Model | AuthenticationClient for OAuth tokens and the Scopes enum for OSS permissions. |
Autodesk.Oss + Autodesk.Oss.Model | OssClient plus the Region, PolicyKey, CreateBucketsPayload, and BucketsItems types used by the tools below. |
Autodesk.SDKManager | SDKManager / SdkManagerBuilder — the shared HTTP and configuration layer both clients sit on top of. |
ModelContextProtocol.Server | The MCP SDK surface — AddMcpServer, WithHttpTransport, [McpServerTool], [McpServerToolType]. |
System.ComponentModel | Provides [Description] so tools and parameters can carry metadata for the LLM. |
System.Text.Json + Serialization | Serialises enums as snake_case strings on the wire (Region.US → "us"). |
Section 1 - Authentication: getting a 2-legged token
APS uses OAuth 2.0. For server-to-server access, you request a token using your client ID and secret. The token expires after 1 hour, so we cache it and refresh automatically.
const { APS_CLIENT_ID, APS_CLIENT_SECRET } = process.env;
if (!APS_CLIENT_ID || !APS_CLIENT_SECRET) {
throw new Error("Missing APS_CLIENT_ID or APS_CLIENT_SECRET in environment.");
}
const authClient = new AuthenticationClient();
let cachedToken = null;
let tokenExpiresAt = 0;
async function getToken() {
// Return cached token if still valid (with 60s buffer)
if (cachedToken && Date.now() < tokenExpiresAt - 60_000) {
return cachedToken;
}
const token = await authClient.getTwoLeggedToken(
APS_CLIENT_ID,
APS_CLIENT_SECRET,
[Scopes.DataRead, Scopes.DataWrite, Scopes.BucketRead, Scopes.BucketCreate],
);
cachedToken = token.access_token;
tokenExpiresAt = token.expires_at; // already in milliseconds, no * 1000 needed
console.log("APS token refreshed.");
return cachedToken;
}public sealed class ApsTokenProvider
{
private readonly AuthenticationClient _authClient;
private readonly string _clientId;
private readonly string _clientSecret;
private readonly SemaphoreSlim _gate = new(1, 1);
private string? _cachedToken;
private DateTimeOffset _expiresAt = DateTimeOffset.MinValue;
public ApsTokenProvider(AuthenticationClient authClient, IConfiguration config)
{
_authClient = authClient;
_clientId = config["APS_CLIENT_ID"]
?? throw new InvalidOperationException("Missing APS_CLIENT_ID in environment.");
_clientSecret = config["APS_CLIENT_SECRET"]
?? throw new InvalidOperationException("Missing APS_CLIENT_SECRET in environment.");
}
public async Task<string> GetTokenAsync(CancellationToken cancellationToken = default)
{
if (_cachedToken is not null && DateTimeOffset.UtcNow < _expiresAt.AddSeconds(-60))
return _cachedToken;
await _gate.WaitAsync(cancellationToken);
try
{
if (_cachedToken is not null && DateTimeOffset.UtcNow < _expiresAt.AddSeconds(-60))
return _cachedToken;
var token = await _authClient.GetTwoLeggedTokenAsync(
_clientId,
_clientSecret,
new List<Scopes> { Scopes.DataRead, Scopes.DataWrite, Scopes.BucketRead, Scopes.BucketCreate });
_cachedToken = token.AccessToken;
_expiresAt = DateTimeOffset.UtcNow.AddSeconds((double)(token.ExpiresIn ?? 3600));
Console.WriteLine("APS token refreshed.");
return _cachedToken!;
}
finally
{
_gate.Release();
}
}
}Every tool handler calls the token helper before making an API request. If the token is still valid it is returned from cache; if it has expired a new one is fetched transparently.
- Node.js —
getToken()is a module-level function with the cache held in two closure variables. - .NET —
ApsTokenProvideris registered as a DI singleton so every tool gets the same cached token. TheSemaphoreSlimguards the cache against concurrent refreshes when multiple tool calls land at the same time.IConfigurationis populated automatically from User Secrets when running in Development.
To expose the SDK clients to your tools:
// Helper factory: creates a fresh OSS client with the current token
async function getOssClient() {
return new OssClient({
authenticationProvider: new StaticAuthenticationProvider(await getToken()),
});
}builder.Services.AddSingleton<SDKManager>(_ =>
SdkManagerBuilder.Create()
.Add(new ApsConfiguration())
.Add(ResiliencyConfiguration.CreateDefault())
.Build());
builder.Services.AddSingleton<AuthenticationClient>(sp =>
new AuthenticationClient(sp.GetRequiredService<SDKManager>()));
builder.Services.AddSingleton<OssClient>(sp =>
new OssClient(sp.GetRequiredService<SDKManager>()));
builder.Services.AddSingleton<ApsTokenProvider>();- Node.js — A fresh
OssClientis created per tool call becauseStaticAuthenticationProviderbinds to a snapshot of the token; building a new client picks up the latest one transparently. - .NET — The
OssClientis registered as a singleton because the .NET SDK accepts the token at the call site (e.g.oss.GetBucketsAsync(accessToken: ...)) rather than baking it into the client.
Section 2 - Tool: list your OSS buckets
OSS (Object Storage Service) is where applications store raw design files. Translation results are stored by the Model Derivative service. This tool lists all buckets belonging to your application. Using the OSS SDK there are no manual URLs or Authorization headers.
We'll register one tool here:
list_buckets: takes aregionand returns all OSS buckets belonging to your APS application in that region, showing each bucket's key, retention policy, and creation date.
function createServer() {
const server = new McpServer({ name: "devcon-aps-server", version: "1.0.0" });
server.registerTool(
"list_buckets",
{
description:
"Lists all OSS storage buckets belonging to this APS application",
inputSchema: {
region: z
.enum(["US", "EMEA", "AUS", "CAN", "DEU", "IND", "JPN", "GBR"])
.describe("Data center region to list buckets from"),
},
},
async ({ region }) => {
const oss = await getOssClient();
const data = await oss.getBuckets({ region });
const items = data.items ?? [];
if (items.length === 0) {
return {
content: [
{ type: "text", text: "No buckets found. Create one first." },
],
};
}
const lines = items.map(
(b) =>
`${b.bucketKey} (${b.policyKey}, created: ${new Date(b.createdDate).toLocaleDateString()})`,
);
return { content: [{ type: "text", text: lines.join("\n") }] };
},
);
// see Section 3
return server;
}[McpServerToolType]
public class ApsTools
{
[McpServerTool(Name = "list_buckets"),
Description("Lists all OSS storage buckets belonging to this APS application")]
public static async Task<string> ListBuckets(
ApsTokenProvider tokens,
OssClient oss,
[Description("Data center region to list buckets from")] Region region)
{
var accessToken = await tokens.GetTokenAsync();
var data = await oss.GetBucketsAsync(accessToken: accessToken, region: region);
var items = data.Items ?? new List<BucketsItems>();
if (items.Count == 0)
return "No buckets found. Create one first.";
var lines = items.Select(b =>
$"{b.BucketKey} ({b.PolicyKey}, created: {DateTimeOffset.FromUnixTimeMilliseconds(b.CreatedDate ?? 0L):yyyy-MM-dd})");
return string.Join("\n", lines);
}
// see Section 3
}- Node.js —
regionis a Zod enum, validated before the handler runs. - .NET —
Regionis an SDK-provided enum; the MCP SDK turns it into a JSON schemaenumautomatically.ApsTokenProviderandOssClientare resolved from the DI container as method parameters — anything not marked with[Description]is treated as an injected service.
Section 3 - Tool: create a bucket
With list_buckets in place (probably returning empty on a fresh account), the next piece is creating a bucket to store objects in.
We'll register one more tool:
create_bucket: takes abucket_key, apolicy(transient/temporary/persistent), and aregion, and creates a new OSS bucket under your APS application.
The policyKey controls retention:
| Policy | Retention |
|---|---|
transient | 24 hours |
temporary | 30 days |
persistent | Indefinite |
Add this next to the list_buckets tool — inside createServer() for Node.js, inside ApsTools for .NET (replace the // see Section 3 comment):
server.registerTool(
"create_bucket",
{
description: "Creates a new OSS storage bucket",
inputSchema: {
bucket_key: z
.string()
.describe(
"Unique key for the bucket. Lowercase letters, numbers, and dashes only.",
),
policy: z
.enum(["transient", "temporary", "persistent"])
.describe(
"Retention policy: transient (24h), temporary (30 days), persistent (indefinite)",
),
region: z
.enum(["US", "EMEA", "AUS", "CAN", "DEU", "IND", "JPN", "GBR"])
.describe("Data center region for the bucket"),
},
},
async ({ bucket_key, policy, region }) => {
const oss = await getOssClient();
try {
await oss.createBucket(region, {
bucketKey: bucket_key,
policyKey: policy,
});
return {
content: [
{
type: "text",
text: `Bucket '${bucket_key}' created with policy '${policy}'.`,
},
],
};
} catch (err) {
const msg = err?.message ?? String(err);
return { content: [{ type: "text", text: `Error: ${msg}` }] };
}
},
);[McpServerTool(Name = "create_bucket"), Description("Creates a new OSS storage bucket")]
public static async Task<string> CreateBucket(
ApsTokenProvider tokens,
OssClient oss,
[Description("Unique key for the bucket. Lowercase letters, numbers, and dashes only.")] string bucket_key,
[Description("Retention policy: transient (24h), temporary (30 days), persistent (indefinite)")] PolicyKey policy,
[Description("Data center region for the bucket")] Region region)
{
try
{
var accessToken = await tokens.GetTokenAsync();
await oss.CreateBucketAsync(
accessToken: accessToken,
xAdsRegion: region,
bucketsPayload: new CreateBucketsPayload
{
BucketKey = bucket_key,
PolicyKey = policy,
});
return $"Bucket '{bucket_key}' created with policy '{policy.ToString().ToLowerInvariant()}'.";
}
catch (Exception ex)
{
return $"Error: {ex.Message}";
}
}Section 4 - Create the HTTP server and start on port 3001
const httpServer = http.createServer(async (req, res) => {
if (req.url !== "/mcp") {
res.writeHead(404).end("Not found");
return;
}
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on("close", () => transport.close());
const server = createServer(); // fresh McpServer per request
await server.connect(transport);
await transport.handleRequest(req, res);
});
httpServer.listen(3001, () => {
console.log("APS MCP server running at http://localhost:3001/mcp");
});public class ApsServer
{
public static async Task Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<JsonSerializerOptions>(o =>
o.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower)));
// DI registrations from Section 1 go here (SDKManager, AuthenticationClient,
// OssClient, ApsTokenProvider)
builder.Services
.AddMcpServer(o =>
{
o.ServerInfo = new() { Name = "devcon-aps-server", Version = "1.0.0" };
})
.WithHttpTransport(options => options.Stateless = true)
.WithTools<ApsTools>();
var app = builder.Build();
app.Urls.Add("http://localhost:3001");
app.MapMcp("/mcp");
Console.WriteLine("APS MCP server running at http://localhost:3001/mcp");
await app.RunAsync();
}
}- Node.js — Because each
McpServerinstance can only handle one active transport, we use thecreateServer()factory pattern, a fresh server (and transport) per HTTP request. - .NET —
WithHttpTransport(options => options.Stateless = true)gives each request its own session; no factory needed. TheJsonStringEnumConverterlowercases enum names so the schema sent to clients matches theRegion/PolicyKeyvalues.
View complete aps-server.js in Source Code → · View complete ApsServer.cs in Source Code →
Connect VS Code Copilot to the APS Server
Instead of routing APS calls through the workshop server, you connect VS Code Copilot directly to the APS server as a second MCP server. This is MCP's one-to-many model: one client, multiple servers, all tools available in one place.
Open .vscode/mcp.json and add the APS server alongside the existing workshop server. The configuration is identical for both stacks — the client only sees URLs.
{
"servers": {
"devcon-workshop": {
"type": "http",
"url": "http://localhost:3000/mcp"
},
"devcon-aps": {
"type": "http",
"url": "http://localhost:3001/mcp"
}
}
}That's it. VS Code Copilot now connects to both servers on startup and merges their tools into a single unified set. No delegating tools, no proxy layer, each server owns its own tools.
View complete server.js in Source Code → · View complete Server.cs in Source Code →
Run Everything
Start each server in its own terminal:
# Terminal 1 - APS server (requires .env with your credentials)
node aps-server.js
# Terminal 2 - workshop server
node server.js# Terminal 1 - APS server (uses the StartupObject set in DevconWorkshop.csproj)
dotnet run
# Terminal 2 - workshop server (override StartupObject to run Server.cs)
dotnet run -p:StartupObject=DevconWorkshop.ServerRun MCP: List Servers → Start in the Command Palette for both servers.
With both servers running, open Copilot Chat in Agent mode and ask:
What OSS buckets do I have in the US region of my APS account?Create a new OSS bucket called `devcon-workshop-test` with a persistent policy in the EMEA region.List my buckets in the EMEA region again to confirm it was created.Bucket names must be globally unique and contain only lowercase letters, numbers, and dashes. If
devcon-workshop-testis already taken, use a different name (e.g. append your initials:devcon-workshop-test-abc).
On the last two prompts, watch Copilot call create_bucket then list_buckets automatically. You wrote zero orchestration code. Copilot figured out which server to call and how to combine the results.
Further Reading
The 2-legged token used here is limited to app-owned resources, OSS is one of them. Forma projects, issues, and user data are user-scoped and require a 3-legged token where the actual user logs in. That is covered in Chapter 4.
Autodesk's sample aps-mcp-server-nodejs uses Secure Service Accounts (SSA) for fine-grained per-user Forma access. It is built on stdio and designed for Claude Desktop, VS Code or Cursor rather than the HTTP-first system we built here. Explore it at github.com/autodesk-platform-services/aps-mcp-server-nodejs after the workshop.
The Full Architecture
You (natural language)
↓
VS Code Copilot
┌────────┴────────┐
↓ ↓
Workshop server APS server
:3000 :3001
(server.js or (aps-server.js or
Server.cs) ApsServer.cs)
↓ ↓
Open-Meteo APS API (OSS)
(HTTPS) ├── list_buckets
└── create_bucketVS Code Copilot connects to both servers directly. Each server is independent. Copilot sees one unified set of tools and decides how to combine them, that's the one-to-many model.
Tip: MCP tool responses can also include
structuredContentalongside thecontentarray. This is useful for returning richer, machine-readable data (e.g., JSON objects) that clients can display differently from the plain text incontent. We keep things simple with text-only responses in this workshop, but it's good to know the option exists.