Skip to content

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:

WhatWhereStatus
Sample APS MCP server (Node.js)github.com/autodesk-platform-services/aps-mcp-server-nodejsPublic, experimental
Official AEC Data Model MCP server (.NET)github.com/autodesk-platform-services/aps-aecdm-mcp-dotnetPublic, experimental
Autodesk-hosted MCP servers (enterprise)autodesk.com/solutions/autodesk-ai/autodesk-mcp-serversAnnounced, 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:

text
devcon-workshop/
├── .vscode/
│   └── mcp.json
├── node_modules/
├── server.js
├── package.json
└── package-lock.json
text
devcon-workshop/
├── .vscode/
│   └── mcp.json
├── Properties/
│   └── launchSettings.json
├── appsettings.Development.json
├── appsettings.json
├── DevconWorkshop.csproj
└── Server.cs

Store 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.

bash
APS_CLIENT_ID="your-client-id-here"
APS_CLIENT_SECRET="your-client-secret-here"
bash
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 .env to git. Add it to .gitignore.

.NET — dotnet user-secrets stores values under your user profile (outside the project folder), so they are never accidentally checked in. They are read by IConfiguration automatically when running in Development.

Install the credential loader (Node.js only) and the APS SDK packages:

bash
npm install dotenv
npm install @aps_sdk/autodesk-sdkmanager @aps_sdk/authentication @aps_sdk/oss
bash
dotnet 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
PackageStackWhat it covers
dotenvNode.jsLoads .env into process.env
@aps_sdk/autodesk-sdkmanagerNode.jsShared SDK manager and auth provider utilities
@aps_sdk/authenticationNode.js2-legged and 3-legged OAuth tokens
@aps_sdk/ossNode.jsObject Storage Service (buckets and objects)
Autodesk.SDKManager.NETShared SDK manager and DI plumbing
Autodesk.Authentication.NET2-legged and 3-legged OAuth tokens
Autodesk.Oss.NETObject 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.

text
devcon-workshop/
├── .vscode/
│   └── mcp.json
├── node_modules/
├── .env
├── aps-server.js     ← new
├── package.json
├── package-lock.json
└── server.js
text
devcon-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 second Main method (in ApsServer.cs) makes the compiler ambiguous about which one to run. Inside the <PropertyGroup> of DevconWorkshop.csproj, add <StartupObject>DevconWorkshop.ApsServer</StartupObject>. The default dotnet run will then start the APS server; later we'll override this flag to run the workshop server instead.

The Imports / Usings - What Each One Does

javascript
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";
csharp
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

ImportWhat it does
McpServerCreates the APS MCP server instance
StreamableHTTPServerTransportExposes it over HTTP - same pattern as every other server in this workshop
zValidates tool inputs
httpNode.js built-in HTTP server on port 3001
dotenv/configLoads APS_CLIENT_ID and APS_CLIENT_SECRET from your .env file
AuthenticationClient, ScopesSDK client for OAuth tokens; Scopes is the enum of permission scopes
StaticAuthenticationProviderWraps a token string so SDK clients can use it
OssClientSDK client for Object Storage Service, no manual URLs needed

.NET — what each using does

UsingWhat it does
Autodesk.Authentication + Autodesk.Authentication.ModelAuthenticationClient for OAuth tokens and the Scopes enum for OSS permissions.
Autodesk.Oss + Autodesk.Oss.ModelOssClient plus the Region, PolicyKey, CreateBucketsPayload, and BucketsItems types used by the tools below.
Autodesk.SDKManagerSDKManager / SdkManagerBuilder — the shared HTTP and configuration layer both clients sit on top of.
ModelContextProtocol.ServerThe MCP SDK surface — AddMcpServer, WithHttpTransport, [McpServerTool], [McpServerToolType].
System.ComponentModelProvides [Description] so tools and parameters can carry metadata for the LLM.
System.Text.Json + SerializationSerialises 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.

javascript
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;
}
csharp
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.jsgetToken() is a module-level function with the cache held in two closure variables.
  • .NETApsTokenProvider is registered as a DI singleton so every tool gets the same cached token. The SemaphoreSlim guards the cache against concurrent refreshes when multiple tool calls land at the same time. IConfiguration is populated automatically from User Secrets when running in Development.

To expose the SDK clients to your tools:

javascript
// Helper factory: creates a fresh OSS client with the current token
async function getOssClient() {
  return new OssClient({
    authenticationProvider: new StaticAuthenticationProvider(await getToken()),
  });
}
csharp
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 OssClient is created per tool call because StaticAuthenticationProvider binds to a snapshot of the token; building a new client picks up the latest one transparently.
  • .NET — The OssClient is 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 a region and returns all OSS buckets belonging to your APS application in that region, showing each bucket's key, retention policy, and creation date.
javascript
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;
}
csharp
[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.jsregion is a Zod enum, validated before the handler runs.
  • .NETRegion is an SDK-provided enum; the MCP SDK turns it into a JSON schema enum automatically. ApsTokenProvider and OssClient are 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 a bucket_key, a policy (transient / temporary / persistent), and a region, and creates a new OSS bucket under your APS application.

The policyKey controls retention:

PolicyRetention
transient24 hours
temporary30 days
persistentIndefinite

Add this next to the list_buckets tool — inside createServer() for Node.js, inside ApsTools for .NET (replace the // see Section 3 comment):

javascript
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}` }] };
    }
  },
);
csharp
[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

javascript
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");
});
csharp
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 McpServer instance can only handle one active transport, we use the createServer() factory pattern, a fresh server (and transport) per HTTP request.
  • .NETWithHttpTransport(options => options.Stateless = true) gives each request its own session; no factory needed. The JsonStringEnumConverter lowercases enum names so the schema sent to clients matches the Region/PolicyKey values.

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.

json
{
  "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:

bash
# Terminal 1 - APS server (requires .env with your credentials)
node aps-server.js

# Terminal 2 - workshop server
node server.js
bash
# 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.Server

Run 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-test is 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_bucket

VS 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 structuredContent alongside the content array. This is useful for returning richer, machine-readable data (e.g., JSON objects) that clients can display differently from the plain text in content. We keep things simple with text-only responses in this workshop, but it's good to know the option exists.

DevCon MCP Workshop