Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,20 @@
"env": {
"APS_CLIENT_ID": "${user_config.aps_client_id}",
"APS_CLIENT_SECRET": "${user_config.aps_client_secret}",
"APS_SCOPE": "${user_config.aps_scope}"
"APS_SCOPE": "${user_config.aps_scope}",
"APS_CALLBACK_PORT": "${user_config.aps_callback_port}"
}
}
},
"tools": [
{
"name": "aps_login",
"description": "Start 3-legged OAuth login (opens browser). Gives user-context access to APS."
},
{
"name": "aps_logout",
"description": "Clear 3-legged session. Falls back to 2-legged (app) token."
},
{
"name": "aps_get_token",
"description": "Verify APS credentials and obtain a 2-legged access token."
Expand Down Expand Up @@ -58,6 +67,42 @@
{
"name": "aps_docs",
"description": "APS Data Management quick-reference: ID formats, workflows, API paths, error troubleshooting."
},
{
"name": "aps_issues_request",
"description": "Raw ACC Issues API call (construction/issues/v1). Full JSON response. Power-user tool."
},
{
"name": "aps_issues_get_types",
"description": "Get issue categories and types (subtypes) for a project — compact summary with id, title, code."
},
{
"name": "aps_issues_list",
"description": "List/search issues with filtering by status, assignee, type, date, text. Compact summary per issue."
},
{
"name": "aps_issues_get",
"description": "Get detailed info for a single issue: title, status, assignee, dates, custom attributes, comments count."
},
{
"name": "aps_issues_create",
"description": "Create a new issue. Requires title, issueSubtypeId, status. Supports all optional fields."
},
{
"name": "aps_issues_update",
"description": "Update an existing issue. Only send the fields you want to change."
},
{
"name": "aps_issues_get_comments",
"description": "Get all comments for an issue — compact list with body, author, date."
},
{
"name": "aps_issues_create_comment",
"description": "Add a comment to an issue."
},
{
"name": "aps_issues_docs",
"description": "ACC Issues API quick-reference: project ID format, statuses, workflows, filters, error troubleshooting."
}
],
"keywords": ["autodesk", "aps", "mcp", "construction", "bim"],
Expand Down Expand Up @@ -89,6 +134,13 @@
"description": "Space-separated scopes (e.g. data:read or data:read data:write). Default: data:read",
"default": "data:read",
"required": false
},
"aps_callback_port": {
"type": "string",
"title": "3LO callback port",
"description": "Localhost port for the 3-legged OAuth callback. Default: 8910",
"default": "8910",
"required": false
}
}
}
2 changes: 1 addition & 1 deletion scripts/pack-mcpb.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ fs.copyFileSync(path.join(root, "manifest.json"), path.join(buildDir, "manifest.

// Copy server entry (dist -> server/)
const distDir = path.join(root, "dist");
for (const name of ["index.js", "aps-auth.js", "aps-helpers.js"]) {
for (const name of ["index.js", "aps-auth.js", "aps-issues-helpers.js", "aps-dm-helpers.js"]) {
const src = path.join(distDir, name);
if (!fs.existsSync(src)) throw new Error(`Build first: missing ${src}`);
fs.copyFileSync(src, path.join(buildDir, "server", name));
Expand Down
272 changes: 271 additions & 1 deletion src/aps-auth.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
/**
* Autodesk Platform Services (APS) 2-legged OAuth and API helpers.
* Autodesk Platform Services (APS) OAuth helpers.
*
* 2‑legged (client credentials) – getApsToken()
* 3‑legged (authorization code) – performAps3loLogin(), getValid3loToken(), clear3loLogin()
*
* Supports all Data Management API endpoints per datamanagement.yaml.
*/

import { createServer } from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
import { exec } from "node:child_process";
import { homedir } from "node:os";
import { join } from "node:path";
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
unlinkSync,
} from "node:fs";

const APS_TOKEN_URL = "https://developer.api.autodesk.com/authentication/v2/token";
const APS_AUTHORIZE_URL = "https://developer.api.autodesk.com/authentication/v2/authorize";
const APS_BASE = "https://developer.api.autodesk.com";

/** Structured error thrown by APS API calls. Carries status code + body for rich error context. */
Expand Down Expand Up @@ -161,3 +179,255 @@ export async function apsDmRequest(
}
return { ok: true, status: res.status, body: text };
}

// ── 3‑legged OAuth (authorization code + PKCE‑optional) ─────────

/** Shape of the 3LO token data we persist to disk. */
interface Aps3loTokenData {
access_token: string;
refresh_token: string;
expires_at: number; // epoch ms
scope: string;
}

const TOKEN_DIR = join(homedir(), ".aps-mcp");
const TOKEN_FILE = join(TOKEN_DIR, "3lo-tokens.json");

function read3loCache(): Aps3loTokenData | null {
try {
if (!existsSync(TOKEN_FILE)) return null;
return JSON.parse(readFileSync(TOKEN_FILE, "utf8")) as Aps3loTokenData;
} catch {
return null;
}
}

function write3loCache(data: Aps3loTokenData): void {
if (!existsSync(TOKEN_DIR)) mkdirSync(TOKEN_DIR, { recursive: true });
writeFileSync(TOKEN_FILE, JSON.stringify(data, null, 2));
}

function deleteCacheFile(): void {
try {
if (existsSync(TOKEN_FILE)) unlinkSync(TOKEN_FILE);
} catch {
/* ignore */
}
}

/** Open a URL in the user's default browser (cross‑platform). */
function openBrowser(url: string): void {
const cmd =
process.platform === "win32"
? `start "" "${url}"`
: process.platform === "darwin"
? `open "${url}"`
: `xdg-open "${url}"`;
exec(cmd);
}
Comment on lines +228 to +247
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Shell command injection risk in openBrowser.

The URL is directly interpolated into a shell command. While the URL is constructed internally from trusted constants and sanitized query parameters, this pattern is fragile. If the URL ever contains shell metacharacters (e.g., from a malformed scope parameter with backticks or $(...)), it could lead to command injection.

🛡️ Proposed fix using spawn for safer execution
-import { exec } from "node:child_process";
+import { spawn } from "node:child_process";

 /** Open a URL in the user's default browser (cross‑platform). */
 function openBrowser(url: string): void {
-  const cmd =
-    process.platform === "win32"
-      ? `start "" "${url}"`
-      : process.platform === "darwin"
-        ? `open "${url}"`
-        : `xdg-open "${url}"`;
-  exec(cmd);
+  const args = process.platform === "win32" ? ["start", "", url] : [url];
+  const cmd = process.platform === "win32"
+    ? "cmd"
+    : process.platform === "darwin"
+      ? "open"
+      : "xdg-open";
+  const opts = process.platform === "win32" ? { shell: true } : {};
+  spawn(cmd, args, { ...opts, detached: true, stdio: "ignore" }).unref();
 }
🤖 Prompt for AI Agents
In `@src/aps-auth.ts` around lines 228 - 237, The openBrowser function currently
constructs a shell command and calls exec(cmd), which risks command injection;
change it to call a child-process API that takes an argv array
(child_process.spawn or execFile) instead of interpolating the URL into a shell
string. Update openBrowser to pick the platform-specific program name ("open",
"xdg-open", or Windows via "cmd" "/c" "start") and pass the URL as a separate
argument array (no shell=true), and replace exec(cmd) with spawn/program-exec
that forwards stdout/stderr and handles errors; keep the same function name
openBrowser and use process.platform to choose the program and args.


/** In‑memory cache so we don't re‑read the file every call. */
let cached3lo: Aps3loTokenData | null = null;

/**
* Perform the interactive 3‑legged OAuth login.
*
* 1. Spins up a temporary HTTP server on `callbackPort`.
* 2. Opens the user's browser to the APS authorize endpoint.
* 3. Waits for the redirect callback with the authorization code.
* 4. Exchanges the code for access + refresh tokens.
* 5. Persists tokens to `~/.aps-mcp/3lo-tokens.json`.
*
* Resolves when the login is complete or rejects on timeout / error.
*/
export async function performAps3loLogin(
clientId: string,
clientSecret: string,
scope: string,
callbackPort = 8910,
): Promise<{ access_token: string; message: string }> {
const redirectUri = `http://localhost:${callbackPort}/callback`;

return new Promise((resolve, reject) => {
const server = createServer(
async (req: IncomingMessage, res: ServerResponse) => {
const reqUrl = new URL(req.url ?? "/", `http://localhost:${callbackPort}`);

if (reqUrl.pathname !== "/callback") {
res.writeHead(404);
res.end("Not found");
return;
}

const error = reqUrl.searchParams.get("error");
if (error) {
const desc = reqUrl.searchParams.get("error_description") ?? error;
res.writeHead(400, { "Content-Type": "text/html" });
res.end(
`<html><body><h2>Authorization failed</h2><p>${desc}</p></body></html>`,
);
server.close();
reject(new Error(`APS authorization failed: ${desc}`));
return;
}

const code = reqUrl.searchParams.get("code");
if (!code) {
res.writeHead(400, { "Content-Type": "text/html" });
res.end(
"<html><body><h2>Missing authorization code</h2></body></html>",
);
server.close();
reject(new Error("No authorization code received in callback."));
return;
}

// Exchange the authorization code for tokens
try {
const tokenBody = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret,
});

const tokenRes = await fetch(APS_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: tokenBody.toString(),
});

if (!tokenRes.ok) {
const text = await tokenRes.text();
res.writeHead(500, { "Content-Type": "text/html" });
res.end(
`<html><body><h2>Token exchange failed</h2><pre>${text}</pre></body></html>`,
);
server.close();
reject(
new Error(`Token exchange failed (${tokenRes.status}): ${text}`),
);
return;
Comment on lines +325 to +333
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Escape error text in token exchange failure response.

The text from the token exchange failure response is rendered directly into HTML without escaping, similar to the issue that was fixed for error_description.

🛡️ Proposed fix
           if (!tokenRes.ok) {
             const text = await tokenRes.text();
             res.writeHead(500, { "Content-Type": "text/html" });
             res.end(
-              `<html><body><h2>Token exchange failed</h2><pre>${text}</pre></body></html>`,
+              `<html><body><h2>Token exchange failed</h2><pre>${escapeHtml(text)}</pre></body></html>`,
             );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
res.writeHead(500, { "Content-Type": "text/html" });
res.end(
`<html><body><h2>Token exchange failed</h2><pre>${text}</pre></body></html>`,
);
server.close();
reject(
new Error(`Token exchange failed (${tokenRes.status}): ${text}`),
);
return;
res.writeHead(500, { "Content-Type": "text/html" });
res.end(
`<html><body><h2>Token exchange failed</h2><pre>${escapeHtml(text)}</pre></body></html>`,
);
server.close();
reject(
new Error(`Token exchange failed (${tokenRes.status}): ${text}`),
);
return;
🤖 Prompt for AI Agents
In `@src/aps-auth.ts` around lines 314 - 322, The HTML response in the token
exchange failure path embeds raw `text` directly into `res.end(...)`, which can
lead to XSS; update the handler that uses `res.writeHead`, `res.end`,
`server.close`, and `reject` so that `text` (from `tokenRes`) is HTML-escaped
before interpolation into the `<pre>` block (or use an existing escape utility
if one exists), then send the escaped string in the response and include the
escaped text in the Error message passed to `reject` to keep logs consistent and
safe.

}

const data = (await tokenRes.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};

const cacheData: Aps3loTokenData = {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: Date.now() + (data.expires_in - 60) * 1000,
scope,
};
write3loCache(cacheData);
cached3lo = cacheData;

res.writeHead(200, { "Content-Type": "text/html" });
res.end(
"<html><body><h2>Logged in to APS</h2>" +
"<p>You can close this tab and return to Claude Desktop.</p></body></html>",
);
server.close();

resolve({
access_token: data.access_token,
message:
`3-legged login successful. Tokens cached to ${TOKEN_FILE}. ` +
"The token will auto-refresh when it expires.",
});
} catch (err) {
res.writeHead(500, { "Content-Type": "text/html" });
res.end(
`<html><body><h2>Error</h2><pre>${String(err)}</pre></body></html>`,
);
server.close();
reject(err);
}
},
);

server.listen(callbackPort, () => {
const authUrl = new URL(APS_AUTHORIZE_URL);
authUrl.searchParams.set("client_id", clientId);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("scope", scope);
openBrowser(authUrl.toString());
});

// Give the user 2 minutes to complete login
setTimeout(() => {
server.close();
reject(new Error("3LO login timed out after 2 minutes. Try again."));
}, 120_000);
});
}

/**
* Return a valid 3LO access token if one exists (from cache or by refreshing).
* Returns `null` when no 3LO session is active (caller should fall back to 2LO).
*/
export async function getValid3loToken(
clientId: string,
clientSecret: string,
): Promise<string | null> {
if (!cached3lo) cached3lo = read3loCache();
if (!cached3lo) return null;

// Still valid?
if (cached3lo.expires_at > Date.now() + 60_000) {
return cached3lo.access_token;
}

// Attempt refresh
if (cached3lo.refresh_token) {
try {
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: cached3lo.refresh_token,
client_id: clientId,
client_secret: clientSecret,
scope: cached3lo.scope,
});

const res = await fetch(APS_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});

if (res.ok) {
const data = (await res.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
cached3lo = {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: Date.now() + (data.expires_in - 60) * 1000,
scope: cached3lo.scope,
};
write3loCache(cached3lo);
return cached3lo.access_token;
}
} catch {
// refresh failed – fall through to clear
}
}

// Expired and refresh failed
deleteCacheFile();
cached3lo = null;
return null;
}

/** Clear any cached 3LO tokens (in‑memory + on disk). */
export function clear3loLogin(): void {
deleteCacheFile();
cached3lo = null;
}
File renamed without changes.
Loading