diff --git a/manifest.json b/manifest.json
index 4426cf8..482c5eb 100644
--- a/manifest.json
+++ b/manifest.json
@@ -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."
@@ -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"],
@@ -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
}
}
}
diff --git a/scripts/pack-mcpb.mjs b/scripts/pack-mcpb.mjs
index 4a8f3a6..6d7647c 100644
--- a/scripts/pack-mcpb.mjs
+++ b/scripts/pack-mcpb.mjs
@@ -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));
diff --git a/src/aps-auth.ts b/src/aps-auth.ts
index 2da5f92..81a7b04 100644
--- a/src/aps-auth.ts
+++ b/src/aps-auth.ts
@@ -1,9 +1,37 @@
/**
- * 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.
*/
+/** Escape special characters for safe HTML embedding. */
+function escapeHtml(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+import { createServer } from "node:http";
+import type { IncomingMessage, ServerResponse } from "node:http";
+import { spawn } 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. */
@@ -161,3 +189,267 @@ 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 {
+ let program: string;
+ let args: string[];
+
+ if (process.platform === "win32") {
+ program = "cmd";
+ args = ["/c", "start", "", url];
+ } else if (process.platform === "darwin") {
+ program = "open";
+ args = [url];
+ } else {
+ program = "xdg-open";
+ args = [url];
+ }
+
+ const child = spawn(program, args, { stdio: "ignore" });
+ child.on("error", () => { /* ignore – best‑effort */ });
+ child.unref();
+}
+
+/** 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;
+ const safeDesc = escapeHtml(desc);
+ res.writeHead(400, { "Content-Type": "text/html" });
+ res.end(
+ `
Authorization failed
${safeDesc}
`,
+ );
+ 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(
+ "Missing authorization code
",
+ );
+ 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();
+ const safeText = escapeHtml(text);
+ res.writeHead(500, { "Content-Type": "text/html" });
+ res.end(
+ `Token exchange failed
${safeText}`,
+ );
+ server.close();
+ reject(
+ new Error(`Token exchange failed (${tokenRes.status}): ${safeText}`),
+ );
+ return;
+ }
+
+ 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(
+ "Logged in to APS
" +
+ "You can close this tab and return to Claude Desktop.
",
+ );
+ 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(
+ `Error
${String(err)}`,
+ );
+ 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 {
+ 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;
+}
diff --git a/src/aps-helpers.ts b/src/aps-dm-helpers.ts
similarity index 100%
rename from src/aps-helpers.ts
rename to src/aps-dm-helpers.ts
diff --git a/src/aps-issues-helpers.ts b/src/aps-issues-helpers.ts
new file mode 100644
index 0000000..af2bb4a
--- /dev/null
+++ b/src/aps-issues-helpers.ts
@@ -0,0 +1,348 @@
+/**
+ * APS Construction Issues helpers: response summarisers, validators,
+ * project‑ID conversion, and quick‑reference docs.
+ *
+ * Follows the same patterns as aps‑helpers.ts (Data Management).
+ */
+
+// ── Shared tiny types ────────────────────────────────────────────
+
+export interface IssueSummary {
+ id: string;
+ displayId: number;
+ title: string;
+ status: string;
+ assignedTo?: string;
+ assignedToType?: string;
+ dueDate?: string;
+ startDate?: string;
+ locationDetails?: string;
+ rootCauseId?: string;
+ published: boolean;
+ commentCount: number;
+ createdBy: string;
+ createdAt: string;
+ updatedAt: string;
+ closedBy?: string;
+ closedAt?: string;
+}
+
+export interface IssueDetailSummary extends IssueSummary {
+ description?: string;
+ issueTypeId?: string;
+ issueSubtypeId?: string;
+ locationId?: string;
+ customAttributes?: { id: string; value: unknown; type?: string; title?: string }[];
+ linkedDocumentCount: number;
+ watchers?: string[];
+ permittedStatuses?: string[];
+}
+
+export interface IssueTypeSummary {
+ id: string;
+ title: string;
+ isActive: boolean;
+ subtypes?: { id: string; title: string; code?: string; isActive: boolean }[];
+}
+
+export interface IssueCommentSummary {
+ id: string;
+ body: string;
+ createdBy: string;
+ createdAt: string;
+}
+
+export interface RootCauseCategorySummary {
+ id: string;
+ title: string;
+ isActive: boolean;
+ rootCauses?: { id: string; title: string; isActive: boolean }[];
+}
+
+// ── Project ID helper ────────────────────────────────────────────
+
+/**
+ * Strip the 'b.' prefix from a project ID for the ACC Issues API.
+ * The Issues API uses raw project GUIDs, while the Data Management
+ * API uses 'b.'‑prefixed IDs. Accepts either format.
+ */
+export function toIssuesProjectId(projectId: string): string {
+ return projectId.replace(/^b\./, "");
+}
+
+// ── Response summarisers ─────────────────────────────────────────
+
+/** Extract pagination from raw response. */
+function extractPagination(r: Record | undefined): { limit: number; offset: number; totalResults: number } {
+ const p = r?.pagination as Record | undefined;
+ return {
+ limit: (p?.limit as number) ?? 0,
+ offset: (p?.offset as number) ?? 0,
+ totalResults: (p?.totalResults as number) ?? 0,
+ };
+}
+
+/** Summarise a paginated issues list – drops permittedActions/Attributes, linkedDocuments, etc. */
+export function summarizeIssuesList(raw: unknown): {
+ pagination: { limit: number; offset: number; totalResults: number };
+ issues: IssueSummary[];
+} {
+ const r = raw as Record | undefined;
+ const pagination = extractPagination(r);
+ const results = Array.isArray(r?.results)
+ ? (r!.results as Record[])
+ : [];
+
+ const issues: IssueSummary[] = results.map((issue) => ({
+ id: issue.id as string,
+ displayId: (issue.displayId as number) ?? 0,
+ title: (issue.title as string) ?? "",
+ status: (issue.status as string) ?? "",
+ assignedTo: (issue.assignedTo as string) ?? undefined,
+ assignedToType: (issue.assignedToType as string) ?? undefined,
+ dueDate: (issue.dueDate as string) ?? undefined,
+ startDate: (issue.startDate as string) ?? undefined,
+ locationDetails: (issue.locationDetails as string) ?? undefined,
+ rootCauseId: (issue.rootCauseId as string) ?? undefined,
+ published: (issue.published as boolean) ?? false,
+ commentCount: (issue.commentCount as number) ?? 0,
+ createdBy: (issue.createdBy as string) ?? "",
+ createdAt: (issue.createdAt as string) ?? "",
+ updatedAt: (issue.updatedAt as string) ?? "",
+ closedBy: (issue.closedBy as string) ?? undefined,
+ closedAt: (issue.closedAt as string) ?? undefined,
+ }));
+
+ return { pagination, issues };
+}
+
+/** Summarise a single issue response – keeps more detail than the list summary. */
+export function summarizeIssueDetail(raw: unknown): IssueDetailSummary {
+ const issue = raw as Record;
+
+ const customAttrs = Array.isArray(issue.customAttributes)
+ ? (issue.customAttributes as Record[]).map((ca) => ({
+ id: (ca.attributeDefinitionId as string) ?? "",
+ value: ca.value,
+ type: (ca.type as string) ?? undefined,
+ title: (ca.title as string) ?? undefined,
+ }))
+ : undefined;
+
+ const linkedDocs = Array.isArray(issue.linkedDocuments)
+ ? issue.linkedDocuments.length
+ : 0;
+
+ return {
+ id: issue.id as string,
+ displayId: (issue.displayId as number) ?? 0,
+ title: (issue.title as string) ?? "",
+ description: (issue.description as string) ?? undefined,
+ status: (issue.status as string) ?? "",
+ issueTypeId: (issue.issueTypeId as string) ?? undefined,
+ issueSubtypeId: (issue.issueSubtypeId as string) ?? undefined,
+ assignedTo: (issue.assignedTo as string) ?? undefined,
+ assignedToType: (issue.assignedToType as string) ?? undefined,
+ dueDate: (issue.dueDate as string) ?? undefined,
+ startDate: (issue.startDate as string) ?? undefined,
+ locationId: (issue.locationId as string) ?? undefined,
+ locationDetails: (issue.locationDetails as string) ?? undefined,
+ rootCauseId: (issue.rootCauseId as string) ?? undefined,
+ published: (issue.published as boolean) ?? false,
+ commentCount: (issue.commentCount as number) ?? 0,
+ createdBy: (issue.createdBy as string) ?? "",
+ createdAt: (issue.createdAt as string) ?? "",
+ updatedAt: (issue.updatedAt as string) ?? "",
+ closedBy: (issue.closedBy as string) ?? undefined,
+ closedAt: (issue.closedAt as string) ?? undefined,
+ customAttributes: customAttrs,
+ linkedDocumentCount: linkedDocs,
+ watchers: Array.isArray(issue.watchers)
+ ? (issue.watchers as string[])
+ : undefined,
+ permittedStatuses: Array.isArray(issue.permittedStatuses)
+ ? (issue.permittedStatuses as string[])
+ : undefined,
+ };
+}
+
+/** Summarise issue types/categories response. */
+export function summarizeIssueTypes(raw: unknown): {
+ pagination: { limit: number; offset: number; totalResults: number };
+ types: IssueTypeSummary[];
+} {
+ const r = raw as Record | undefined;
+ const pagination = extractPagination(r);
+ const results = Array.isArray(r?.results)
+ ? (r!.results as Record[])
+ : [];
+
+ const types: IssueTypeSummary[] = results.map((t) => {
+ const subtypes = Array.isArray(t.subtypes)
+ ? (t.subtypes as Record[]).map((st) => ({
+ id: st.id as string,
+ title: (st.title as string) ?? "",
+ code: (st.code as string) ?? undefined,
+ isActive: (st.isActive as boolean) ?? false,
+ }))
+ : undefined;
+
+ return {
+ id: t.id as string,
+ title: (t.title as string) ?? "",
+ isActive: (t.isActive as boolean) ?? false,
+ subtypes,
+ };
+ });
+
+ return { pagination, types };
+}
+
+/** Summarise issue comments response. */
+export function summarizeComments(raw: unknown): {
+ pagination: { limit: number; offset: number; totalResults: number };
+ comments: IssueCommentSummary[];
+} {
+ const r = raw as Record | undefined;
+ const pagination = extractPagination(r);
+ const results = Array.isArray(r?.results)
+ ? (r!.results as Record[])
+ : [];
+
+ const comments: IssueCommentSummary[] = results.map((c) => ({
+ id: c.id as string,
+ body: (c.body as string) ?? "",
+ createdBy: (c.createdBy as string) ?? "",
+ createdAt: (c.createdAt as string) ?? "",
+ }));
+
+ return { pagination, comments };
+}
+
+/** Summarise root cause categories response. */
+export function summarizeRootCauseCategories(raw: unknown): {
+ pagination: { limit: number; offset: number; totalResults: number };
+ categories: RootCauseCategorySummary[];
+} {
+ const r = raw as Record | undefined;
+ const pagination = extractPagination(r);
+ const results = Array.isArray(r?.results)
+ ? (r!.results as Record[])
+ : [];
+
+ const categories: RootCauseCategorySummary[] = results.map((cat) => {
+ const rootCauses = Array.isArray(cat.rootCauses)
+ ? (cat.rootCauses as Record[]).map((rc) => ({
+ id: rc.id as string,
+ title: (rc.title as string) ?? "",
+ isActive: (rc.isActive as boolean) ?? false,
+ }))
+ : undefined;
+
+ return {
+ id: cat.id as string,
+ title: (cat.title as string) ?? "",
+ isActive: (cat.isActive as boolean) ?? false,
+ rootCauses,
+ };
+ });
+
+ return { pagination, categories };
+}
+
+// ── Parameter validation ─────────────────────────────────────────
+
+export function validateIssuesProjectId(id: string): string | null {
+ if (!id) return "project_id is required.";
+ return null;
+}
+
+export function validateIssueId(id: string): string | null {
+ if (!id) return "issue_id is required.";
+ return null;
+}
+
+export function validateIssuesPath(path: string): string | null {
+ if (!path || typeof path !== "string")
+ return "path is required and must be a non‑empty string.";
+ if (path.includes("..")) return "path must not contain '..'.";
+ return null;
+}
+
+// ── Quick‑reference documentation ────────────────────────────────
+
+export const ISSUES_DOCS = `# ACC Issues API – Quick Reference
+
+## Important: Project ID Format
+The Issues API uses project IDs **without** the 'b.' prefix.
+- Data Management ID: \`b.a4be0c34a-4ab7\`
+- Issues API ID: \`a4be0c34a-4ab7\`
+
+The simplified tools handle this conversion automatically – you can pass either format.
+
+## Statuses
+\`draft\` → \`open\` → \`pending\` / \`in_progress\` / \`in_review\` / \`completed\` / \`not_approved\` / \`in_dispute\` → \`closed\`
+
+## Typical Workflow
+\`\`\`
+1. aps_issues_get_types project_id → get issue categories & types
+2. aps_issues_list project_id + filters → browse issues
+3. aps_issues_get project_id + issue_id → single issue details
+4. aps_issues_create project_id + title + subtype → create new issue
+5. aps_issues_update project_id + issue_id + fields → update issue
+6. aps_issues_get_comments project_id + issue_id → read comments
+7. aps_issues_create_comment project_id + issue_id + body → add comment
+\`\`\`
+
+## Raw API Paths (for aps_issues_request)
+| Action | Method | Path |
+|--------|--------|------|
+| User profile | GET | construction/issues/v1/projects/{projectId}/users/me |
+| Issue types | GET | construction/issues/v1/projects/{projectId}/issue-types?include=subtypes |
+| Attribute definitions | GET | construction/issues/v1/projects/{projectId}/issue-attribute-definitions |
+| Attribute mappings | GET | construction/issues/v1/projects/{projectId}/issue-attribute-mappings |
+| Root cause categories | GET | construction/issues/v1/projects/{projectId}/issue-root-cause-categories?include=rootcauses |
+| List issues | GET | construction/issues/v1/projects/{projectId}/issues |
+| Create issue | POST | construction/issues/v1/projects/{projectId}/issues |
+| Get issue | GET | construction/issues/v1/projects/{projectId}/issues/{issueId} |
+| Update issue | PATCH | construction/issues/v1/projects/{projectId}/issues/{issueId} |
+| List comments | GET | construction/issues/v1/projects/{projectId}/issues/{issueId}/comments |
+| Create comment | POST | construction/issues/v1/projects/{projectId}/issues/{issueId}/comments |
+
+## Common Filters (for aps_issues_list)
+- \`filter[status]\` – open, closed, pending, in_progress, etc.
+- \`filter[assignedTo]\` – Autodesk user/company/role ID
+- \`filter[issueTypeId]\` – category UUID
+- \`filter[issueSubtypeId]\` – type UUID
+- \`filter[dueDate]\` – YYYY-MM-DD
+- \`filter[createdAt]\` – YYYY-MM-DDThh:mm:ss.sz
+- \`filter[search]\` – search by title or display ID
+- \`filter[locationId]\` – LBS location UUID
+- \`filter[rootCauseId]\` – root cause UUID
+- \`filter[displayId]\` – chronological issue number
+
+## Sort Options
+\`createdAt\`, \`updatedAt\`, \`displayId\`, \`title\`, \`status\`, \`assignedTo\`, \`dueDate\`, \`startDate\`, \`closedAt\`
+Prefix with \`-\` for descending (e.g. \`-createdAt\`).
+
+## Region Header (x-ads-region)
+Possible values: \`US\` (default), \`EMEA\`, \`AUS\`, \`CAN\`, \`DEU\`, \`IND\`, \`JPN\`, \`GBR\`.
+Pass as the \`region\` parameter on any Issues tool.
+
+## Creating an Issue (required fields)
+- \`title\` – the issue title (max 10,000 chars)
+- \`issueSubtypeId\` – the type UUID (get from aps_issues_get_types)
+- \`status\` – initial status (e.g. 'open')
+
+## Error Troubleshooting
+| Code | Common Cause | Fix |
+|------|-------------|-----|
+| 401 | Expired / invalid token | Check credentials; token auto‑refreshes |
+| 403 | App not provisioned or insufficient scopes | Admin → Account Settings → Custom Integrations. Ensure scope includes 'data:read data:write' for write operations |
+| 404 | Wrong project ID or issue not found | Ensure project ID has no 'b.' prefix for raw API calls |
+| 409 | Conflict (e.g. duplicate) | Check for existing resource |
+| 422 | Attachment limit reached (100/issue) | Remove old attachments first |
+
+## Full Specification
+- OpenAPI: https://github.com/autodesk-platform-services/aps-sdk-openapi/blob/main/construction/issues/Issues.yaml
+`;
diff --git a/src/index.ts b/src/index.ts
index 990f2cb..56bb506 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,8 +1,12 @@
/**
* MCP server for Autodesk Platform Services (APS).
*
- * Tools:
+ * Auth Tools:
+ * aps_login – 3‑legged OAuth login (opens browser)
+ * aps_logout – clear 3‑legged session
* aps_get_token – verify credentials / obtain 2‑legged token
+ *
+ * Data Management Tools:
* aps_dm_request – raw Data Management API (power‑user)
* aps_list_hubs – simplified hub listing
* aps_list_projects – simplified project listing
@@ -11,6 +15,17 @@
* aps_get_item_details – single file / item metadata
* aps_get_folder_tree – recursive folder tree
* aps_docs – APS quick‑reference documentation
+ *
+ * Issues Tools:
+ * aps_issues_request – raw Issues API (power‑user)
+ * aps_issues_get_types – issue categories & types
+ * aps_issues_list – list / search issues (summarised)
+ * aps_issues_get – single issue detail
+ * aps_issues_create – create a new issue
+ * aps_issues_update – update an existing issue
+ * aps_issues_get_comments – list comments on an issue
+ * aps_issues_create_comment – add a comment
+ * aps_issues_docs – Issues API quick‑reference
*/
import { Server } from "@modelcontextprotocol/sdk/server";
@@ -19,7 +34,14 @@ import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
-import { getApsToken, apsDmRequest, ApsApiError } from "./aps-auth.js";
+import {
+ getApsToken,
+ apsDmRequest,
+ ApsApiError,
+ performAps3loLogin,
+ getValid3loToken,
+ clear3loLogin,
+} from "./aps-auth.js";
import {
summarizeHubs,
summarizeProjects,
@@ -34,13 +56,25 @@ import {
validateFolderId,
validateItemId,
APS_DOCS,
-} from "./aps-helpers.js";
+} from "./aps-dm-helpers.js";
+import {
+ toIssuesProjectId,
+ summarizeIssuesList,
+ summarizeIssueDetail,
+ summarizeIssueTypes,
+ summarizeComments,
+ validateIssuesProjectId,
+ validateIssueId,
+ validateIssuesPath,
+ ISSUES_DOCS,
+} from "./aps-issues-helpers.js";
// ── Environment ──────────────────────────────────────────────────
const APS_CLIENT_ID = process.env.APS_CLIENT_ID ?? "";
const APS_CLIENT_SECRET = process.env.APS_CLIENT_SECRET ?? "";
const APS_SCOPE = process.env.APS_SCOPE ?? "";
+const APS_CALLBACK_PORT = parseInt(process.env.APS_CALLBACK_PORT ?? "8910", 10);
function requireApsEnv(): void {
if (!APS_CLIENT_ID || !APS_CLIENT_SECRET) {
@@ -50,9 +84,15 @@ function requireApsEnv(): void {
}
}
-/** Obtain a valid access token (cached automatically). */
+/**
+ * Obtain a valid access token.
+ * Prefers a cached 3‑legged token (user context) when available,
+ * otherwise falls back to 2‑legged (app context).
+ */
async function token(): Promise {
requireApsEnv();
+ const three = await getValid3loToken(APS_CLIENT_ID, APS_CLIENT_SECRET);
+ if (three) return three;
return getApsToken(APS_CLIENT_ID, APS_CLIENT_SECRET, APS_SCOPE || undefined);
}
@@ -79,6 +119,37 @@ function richError(err: ApsApiError) {
// ── Tool definitions ─────────────────────────────────────────────
const TOOLS = [
+ // 0a ── aps_login (3‑legged OAuth)
+ {
+ name: "aps_login",
+ description:
+ "Start a 3‑legged OAuth login for APS (user context). " +
+ "Opens the user's browser to the Autodesk sign‑in page. " +
+ "After the user logs in and grants consent, the token is cached to disk " +
+ "and auto‑refreshed. All subsequent API calls use the 3LO token " +
+ "(with the user's own permissions) until aps_logout is called.",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ scope: {
+ type: "string",
+ description:
+ "OAuth scope(s), space‑separated. " +
+ "Defaults to 'data:read data:write data:create account:read'.",
+ },
+ },
+ },
+ },
+
+ // 0b ── aps_logout (clear 3LO session)
+ {
+ name: "aps_logout",
+ description:
+ "Clear the cached 3‑legged OAuth token. " +
+ "After this, API calls fall back to the 2‑legged (app‑context) token.",
+ inputSchema: { type: "object" as const, properties: {} },
+ },
+
// 1 ── aps_get_token
{
name: "aps_get_token",
@@ -284,6 +355,429 @@ const TOOLS = [
"Call this before your first APS interaction or when unsure about ID formats or API paths.",
inputSchema: { type: "object" as const, properties: {} },
},
+
+ // ═══════════════════════════════════════════════════════════════
+ // ACC Issues Tools
+ // ═══════════════════════════════════════════════════════════════
+
+ // 10 ── aps_issues_request (raw / power‑user)
+ {
+ name: "aps_issues_request",
+ description:
+ "Call any ACC Issues API endpoint (construction/issues/v1). " +
+ "This is the raw / power‑user tool – it returns the full API response. " +
+ "Prefer the simplified tools (aps_issues_list, aps_issues_get, etc.) for everyday use. " +
+ "Use this when you need full control: custom filters, attribute definitions, attribute mappings, " +
+ "or endpoints not covered by simplified tools.\n\n" +
+ "⚠️ Project IDs for the Issues API must NOT have the 'b.' prefix. " +
+ "If you have a Data Management project ID like 'b.abc123', use 'abc123'.",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ method: {
+ type: "string",
+ enum: ["GET", "POST", "PATCH", "DELETE"],
+ description: "HTTP method.",
+ },
+ path: {
+ type: "string",
+ description:
+ "API path relative to developer.api.autodesk.com " +
+ "(e.g. 'construction/issues/v1/projects/{projectId}/issues'). " +
+ "Must include the version prefix (construction/issues/v1).",
+ },
+ query: {
+ type: "object",
+ description:
+ "Optional query parameters as key/value pairs " +
+ "(e.g. { \"filter[status]\": \"open\", \"limit\": \"50\" }).",
+ additionalProperties: { type: "string" },
+ },
+ body: {
+ type: "object",
+ description: "Optional JSON body for POST/PATCH requests.",
+ },
+ region: {
+ type: "string",
+ enum: ["US", "EMEA", "AUS", "CAN", "DEU", "IND", "JPN", "GBR"],
+ description: "Data centre region (x-ads-region header). Defaults to US.",
+ },
+ },
+ required: ["method", "path"],
+ },
+ },
+
+ // 11 ── aps_issues_get_types
+ {
+ name: "aps_issues_get_types",
+ description:
+ "Get issue categories (types) and their types (subtypes) for a project. " +
+ "Returns a compact summary: category id, title, active status, and subtypes with code. " +
+ "Use the returned subtype id when creating issues (issueSubtypeId).",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ project_id: {
+ type: "string",
+ description:
+ "Project ID – accepts with or without 'b.' prefix (e.g. 'b.abc123' or 'abc123'). " +
+ "Get this from aps_list_projects.",
+ },
+ include_subtypes: {
+ type: "boolean",
+ description: "Include subtypes for each category. Defaults to true.",
+ },
+ region: {
+ type: "string",
+ enum: ["US", "EMEA", "AUS", "CAN", "DEU", "IND", "JPN", "GBR"],
+ description: "Data centre region. Defaults to US.",
+ },
+ },
+ required: ["project_id"],
+ },
+ },
+
+ // 12 ── aps_issues_list
+ {
+ name: "aps_issues_list",
+ description:
+ "List and search issues in a project with optional filtering. " +
+ "Returns a compact summary per issue: id, displayId, title, status, assignee, dates, comment count. " +
+ "Supports filtering by status, assignee, type, date, search text, and more. " +
+ "This is much smaller than the raw API response.",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ project_id: {
+ type: "string",
+ description: "Project ID – accepts with or without 'b.' prefix.",
+ },
+ filter_status: {
+ type: "string",
+ description:
+ "Filter by status. Comma‑separated. " +
+ "Values: draft, open, pending, in_progress, in_review, completed, not_approved, in_dispute, closed.",
+ },
+ filter_assigned_to: {
+ type: "string",
+ description: "Filter by assignee Autodesk ID. Comma‑separated for multiple.",
+ },
+ filter_issue_type_id: {
+ type: "string",
+ description: "Filter by category (type) UUID. Comma‑separated for multiple.",
+ },
+ filter_issue_subtype_id: {
+ type: "string",
+ description: "Filter by type (subtype) UUID. Comma‑separated for multiple.",
+ },
+ filter_due_date: {
+ type: "string",
+ description: "Filter by due date (YYYY‑MM‑DD). Comma‑separated for range.",
+ },
+ filter_created_at: {
+ type: "string",
+ description: "Filter by creation date (YYYY‑MM‑DD or YYYY‑MM‑DDThh:mm:ss.sz).",
+ },
+ filter_search: {
+ type: "string",
+ description: "Search by title or display ID (e.g. '300' or 'wall crack').",
+ },
+ filter_root_cause_id: {
+ type: "string",
+ description: "Filter by root cause UUID. Comma‑separated for multiple.",
+ },
+ filter_location_id: {
+ type: "string",
+ description: "Filter by LBS location UUID. Comma‑separated for multiple.",
+ },
+ limit: {
+ type: "number",
+ description: "Max issues to return (1‑100). Default 100.",
+ },
+ offset: {
+ type: "number",
+ description: "Pagination offset. Default 0.",
+ },
+ sort_by: {
+ type: "string",
+ description:
+ "Sort field(s). Comma‑separated. Prefix with '-' for descending. " +
+ "Values: createdAt, updatedAt, displayId, title, status, assignedTo, dueDate, startDate, closedAt.",
+ },
+ region: {
+ type: "string",
+ enum: ["US", "EMEA", "AUS", "CAN", "DEU", "IND", "JPN", "GBR"],
+ description: "Data centre region. Defaults to US.",
+ },
+ },
+ required: ["project_id"],
+ },
+ },
+
+ // 13 ── aps_issues_get
+ {
+ name: "aps_issues_get",
+ description:
+ "Get detailed information about a single issue. " +
+ "Returns a compact summary with: id, title, description, status, assignee, dates, location, " +
+ "custom attributes, linked document count, permitted statuses, and more.",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ project_id: {
+ type: "string",
+ description: "Project ID – accepts with or without 'b.' prefix.",
+ },
+ issue_id: {
+ type: "string",
+ description: "Issue UUID. Get this from aps_issues_list.",
+ },
+ region: {
+ type: "string",
+ enum: ["US", "EMEA", "AUS", "CAN", "DEU", "IND", "JPN", "GBR"],
+ description: "Data centre region. Defaults to US.",
+ },
+ },
+ required: ["project_id", "issue_id"],
+ },
+ },
+
+ // 14 ── aps_issues_create
+ {
+ name: "aps_issues_create",
+ description:
+ "Create a new issue in a project. " +
+ "Requires: title, issueSubtypeId (get from aps_issues_get_types), and status. " +
+ "Optional: description, assignee, dates, location, root cause, custom attributes, watchers. " +
+ "⚠️ Requires 'data:write' in APS_SCOPE.",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ project_id: {
+ type: "string",
+ description: "Project ID – accepts with or without 'b.' prefix.",
+ },
+ title: {
+ type: "string",
+ description: "Issue title (max 10,000 chars).",
+ },
+ issue_subtype_id: {
+ type: "string",
+ description: "Type (subtype) UUID – get from aps_issues_get_types.",
+ },
+ status: {
+ type: "string",
+ enum: ["draft", "open", "pending", "in_progress", "in_review", "completed", "not_approved", "in_dispute", "closed"],
+ description: "Initial status (e.g. 'open').",
+ },
+ description: {
+ type: "string",
+ description: "Issue description (max 10,000 chars). Optional.",
+ },
+ assigned_to: {
+ type: "string",
+ description: "Autodesk ID of assignee (user, company, or role). Optional.",
+ },
+ assigned_to_type: {
+ type: "string",
+ enum: ["user", "company", "role"],
+ description: "Type of assignee. Required if assigned_to is set.",
+ },
+ due_date: {
+ type: "string",
+ description: "Due date in ISO8601 format (e.g. '2025‑12‑31'). Optional.",
+ },
+ start_date: {
+ type: "string",
+ description: "Start date in ISO8601 format. Optional.",
+ },
+ location_id: {
+ type: "string",
+ description: "LBS (Location Breakdown Structure) UUID. Optional.",
+ },
+ location_details: {
+ type: "string",
+ description: "Location as plain text (max 8,300 chars). Optional.",
+ },
+ root_cause_id: {
+ type: "string",
+ description: "Root cause UUID. Optional.",
+ },
+ published: {
+ type: "boolean",
+ description: "Whether the issue is published. Default false.",
+ },
+ watchers: {
+ type: "array",
+ items: { type: "string" },
+ description: "Array of Autodesk IDs to add as watchers. Optional.",
+ },
+ custom_attributes: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ attributeDefinitionId: { type: "string" },
+ value: {},
+ },
+ required: ["attributeDefinitionId", "value"],
+ },
+ description: "Custom attribute values. Optional.",
+ },
+ region: {
+ type: "string",
+ enum: ["US", "EMEA", "AUS", "CAN", "DEU", "IND", "JPN", "GBR"],
+ description: "Data centre region. Defaults to US.",
+ },
+ },
+ required: ["project_id", "title", "issue_subtype_id", "status"],
+ },
+ },
+
+ // 15 ── aps_issues_update
+ {
+ name: "aps_issues_update",
+ description:
+ "Update an existing issue. Only include the fields you want to change. " +
+ "⚠️ Requires 'data:write' in APS_SCOPE. " +
+ "To see which fields the current user can update, check permittedAttributes in the issue detail.",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ project_id: {
+ type: "string",
+ description: "Project ID – accepts with or without 'b.' prefix.",
+ },
+ issue_id: {
+ type: "string",
+ description: "Issue UUID to update.",
+ },
+ title: { type: "string", description: "New title. Optional." },
+ description: { type: "string", description: "New description. Optional." },
+ status: {
+ type: "string",
+ enum: ["draft", "open", "pending", "in_progress", "in_review", "completed", "not_approved", "in_dispute", "closed"],
+ description: "New status. Optional.",
+ },
+ assigned_to: { type: "string", description: "New assignee Autodesk ID. Optional." },
+ assigned_to_type: {
+ type: "string",
+ enum: ["user", "company", "role"],
+ description: "Assignee type. Required if assigned_to is set.",
+ },
+ due_date: { type: "string", description: "New due date (ISO8601). Optional." },
+ start_date: { type: "string", description: "New start date (ISO8601). Optional." },
+ location_id: { type: "string", description: "New LBS location UUID. Optional." },
+ location_details: { type: "string", description: "New location text. Optional." },
+ root_cause_id: { type: "string", description: "New root cause UUID. Optional." },
+ published: { type: "boolean", description: "Set published state. Optional." },
+ watchers: {
+ type: "array",
+ items: { type: "string" },
+ description: "New watcher list. Optional.",
+ },
+ custom_attributes: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ attributeDefinitionId: { type: "string" },
+ value: {},
+ },
+ required: ["attributeDefinitionId", "value"],
+ },
+ description: "Custom attribute values to update. Optional.",
+ },
+ region: {
+ type: "string",
+ enum: ["US", "EMEA", "AUS", "CAN", "DEU", "IND", "JPN", "GBR"],
+ description: "Data centre region. Defaults to US.",
+ },
+ },
+ required: ["project_id", "issue_id"],
+ },
+ },
+
+ // 16 ── aps_issues_get_comments
+ {
+ name: "aps_issues_get_comments",
+ description:
+ "Get all comments for a specific issue. " +
+ "Returns a compact list: comment id, body, author, date.",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ project_id: {
+ type: "string",
+ description: "Project ID – accepts with or without 'b.' prefix.",
+ },
+ issue_id: {
+ type: "string",
+ description: "Issue UUID.",
+ },
+ limit: {
+ type: "number",
+ description: "Max comments to return. Optional.",
+ },
+ offset: {
+ type: "number",
+ description: "Pagination offset. Optional.",
+ },
+ sort_by: {
+ type: "string",
+ description: "Sort field (e.g. 'createdAt' or '-createdAt'). Optional.",
+ },
+ region: {
+ type: "string",
+ enum: ["US", "EMEA", "AUS", "CAN", "DEU", "IND", "JPN", "GBR"],
+ description: "Data centre region. Defaults to US.",
+ },
+ },
+ required: ["project_id", "issue_id"],
+ },
+ },
+
+ // 17 ── aps_issues_create_comment
+ {
+ name: "aps_issues_create_comment",
+ description:
+ "Add a comment to an issue. " +
+ "⚠️ Requires 'data:write' in APS_SCOPE.",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ project_id: {
+ type: "string",
+ description: "Project ID – accepts with or without 'b.' prefix.",
+ },
+ issue_id: {
+ type: "string",
+ description: "Issue UUID.",
+ },
+ body: {
+ type: "string",
+ description: "Comment text (max 10,000 chars). Use \\n for newlines.",
+ },
+ region: {
+ type: "string",
+ enum: ["US", "EMEA", "AUS", "CAN", "DEU", "IND", "JPN", "GBR"],
+ description: "Data centre region. Defaults to US.",
+ },
+ },
+ required: ["project_id", "issue_id", "body"],
+ },
+ },
+
+ // 18 ── aps_issues_docs
+ {
+ name: "aps_issues_docs",
+ description:
+ "Return ACC Issues API quick‑reference documentation: " +
+ "project ID format, statuses, typical workflow, raw API paths, " +
+ "common filters, sort options, and error troubleshooting. " +
+ "Call this before your first Issues interaction.",
+ inputSchema: { type: "object" as const, properties: {} },
+ },
];
// ── Tool handlers ────────────────────────────────────────────────
@@ -292,6 +786,30 @@ async function handleTool(
name: string,
args: Record,
) {
+ // ── aps_login (3LO) ─────────────────────────────────────────
+ if (name === "aps_login") {
+ requireApsEnv();
+ const scope =
+ (args.scope as string | undefined)?.trim() ||
+ APS_SCOPE ||
+ "data:read data:write data:create account:read";
+ const result = await performAps3loLogin(
+ APS_CLIENT_ID,
+ APS_CLIENT_SECRET,
+ scope,
+ APS_CALLBACK_PORT,
+ );
+ return ok(result.message);
+ }
+
+ // ── aps_logout (clear 3LO) ─────────────────────────────────
+ if (name === "aps_logout") {
+ clear3loLogin();
+ return ok(
+ "3-legged session cleared. API calls will now use the 2-legged (app) token.",
+ );
+ }
+
// ── aps_get_token ────────────────────────────────────────────
if (name === "aps_get_token") {
const t = await token();
@@ -442,6 +960,281 @@ async function handleTool(
return ok(APS_DOCS);
}
+ // ═══════════════════════════════════════════════════════════════
+ // ACC Issues Tool Handlers
+ // ═══════════════════════════════════════════════════════════════
+
+ /** Build optional headers for Issues API calls. */
+ function issuesHeaders(region?: string): Record {
+ const h: Record = {};
+ if (region) h["x-ads-region"] = region;
+ return h;
+ }
+
+ /** Build headers for Issues API write operations (POST/PATCH). */
+ function issuesWriteHeaders(region?: string): Record {
+ return { "Content-Type": "application/json", ...issuesHeaders(region) };
+ }
+
+ // ── aps_issues_request ──────────────────────────────────────
+ if (name === "aps_issues_request") {
+ const method = (args.method as string) ?? "GET";
+ const path = args.path as string;
+ const pathErr = validateIssuesPath(path);
+ if (pathErr) return fail(pathErr);
+
+ const query = args.query as Record | undefined;
+ const body = args.body as Record | undefined;
+ const region = args.region as string | undefined;
+ const t = await token();
+
+ const headers: Record = {
+ ...issuesHeaders(region),
+ };
+ if ((method === "POST" || method === "PATCH") && body !== undefined) {
+ headers["Content-Type"] = "application/json";
+ }
+
+ const data = await apsDmRequest(
+ method as "GET" | "POST" | "PATCH" | "DELETE",
+ path,
+ t,
+ { query, body, headers },
+ );
+ return json(data);
+ }
+
+ // ── aps_issues_get_types ────────────────────────────────────
+ if (name === "aps_issues_get_types") {
+ const projectId = args.project_id as string;
+ const err = validateIssuesProjectId(projectId);
+ if (err) return fail(err);
+
+ const pid = toIssuesProjectId(projectId);
+ const includeSubtypes = (args.include_subtypes as boolean) !== false;
+ const region = args.region as string | undefined;
+ const t = await token();
+
+ const query: Record = {};
+ if (includeSubtypes) query.include = "subtypes";
+
+ const raw = await apsDmRequest(
+ "GET",
+ `construction/issues/v1/projects/${pid}/issue-types`,
+ t,
+ { query, headers: issuesHeaders(region) },
+ );
+ return json(summarizeIssueTypes(raw));
+ }
+
+ // ── aps_issues_list ─────────────────────────────────────────
+ if (name === "aps_issues_list") {
+ const projectId = args.project_id as string;
+ const err = validateIssuesProjectId(projectId);
+ if (err) return fail(err);
+
+ const pid = toIssuesProjectId(projectId);
+ const region = args.region as string | undefined;
+ const t = await token();
+
+ const query: Record = {};
+ if (args.filter_status) query["filter[status]"] = args.filter_status as string;
+ if (args.filter_assigned_to) query["filter[assignedTo]"] = args.filter_assigned_to as string;
+ if (args.filter_issue_type_id) query["filter[issueTypeId]"] = args.filter_issue_type_id as string;
+ if (args.filter_issue_subtype_id) query["filter[issueSubtypeId]"] = args.filter_issue_subtype_id as string;
+ if (args.filter_due_date) query["filter[dueDate]"] = args.filter_due_date as string;
+ if (args.filter_created_at) query["filter[createdAt]"] = args.filter_created_at as string;
+ if (args.filter_search) query["filter[search]"] = args.filter_search as string;
+ if (args.filter_root_cause_id) query["filter[rootCauseId]"] = args.filter_root_cause_id as string;
+ if (args.filter_location_id) query["filter[locationId]"] = args.filter_location_id as string;
+ if (args.limit != null) query.limit = String(Math.min(Math.max(Number(args.limit) || 100, 1), 100));
+ if (args.offset != null) query.offset = String(Number(args.offset) || 0);
+ if (args.sort_by) query.sortBy = args.sort_by as string;
+
+ const raw = await apsDmRequest(
+ "GET",
+ `construction/issues/v1/projects/${pid}/issues`,
+ t,
+ { query, headers: issuesHeaders(region) },
+ );
+ return json(summarizeIssuesList(raw));
+ }
+
+ // ── aps_issues_get ──────────────────────────────────────────
+ if (name === "aps_issues_get") {
+ const projectId = args.project_id as string;
+ const issueId = args.issue_id as string;
+ const e1 = validateIssuesProjectId(projectId);
+ if (e1) return fail(e1);
+ const e2 = validateIssueId(issueId);
+ if (e2) return fail(e2);
+
+ const pid = toIssuesProjectId(projectId);
+ const region = args.region as string | undefined;
+ const t = await token();
+
+ const raw = await apsDmRequest(
+ "GET",
+ `construction/issues/v1/projects/${pid}/issues/${issueId}`,
+ t,
+ { headers: issuesHeaders(region) },
+ );
+ return json(summarizeIssueDetail(raw));
+ }
+
+ // ── aps_issues_create ───────────────────────────────────────
+ if (name === "aps_issues_create") {
+ const projectId = args.project_id as string;
+ const err = validateIssuesProjectId(projectId);
+ if (err) return fail(err);
+
+ const title = args.title as string;
+ if (!title) return fail("title is required.");
+ const issueSubtypeId = args.issue_subtype_id as string;
+ if (!issueSubtypeId) return fail("issue_subtype_id is required.");
+ const status = args.status as string;
+ if (!status) return fail("status is required.");
+
+ const pid = toIssuesProjectId(projectId);
+ const region = args.region as string | undefined;
+ const t = await token();
+
+ const body: Record = {
+ title,
+ issueSubtypeId,
+ status,
+ };
+ if (args.description != null) body.description = args.description;
+ if (args.assigned_to && !args.assigned_to_type)
+ return fail("assigned_to_type is required when assigned_to is provided.");
+ if (args.assigned_to != null && args.assigned_to_type != null) {
+ body.assignedTo = args.assigned_to;
+ body.assignedToType = args.assigned_to_type;
+ } else if (args.assigned_to_type != null) {
+ body.assignedToType = args.assigned_to_type;
+ }
+ if (args.due_date != null) body.dueDate = args.due_date;
+ if (args.start_date != null) body.startDate = args.start_date;
+ if (args.location_id != null) body.locationId = args.location_id;
+ if (args.location_details != null) body.locationDetails = args.location_details;
+ if (args.root_cause_id != null) body.rootCauseId = args.root_cause_id;
+ if (args.published != null) body.published = args.published;
+ if (args.watchers != null) body.watchers = args.watchers;
+ if (args.custom_attributes != null) body.customAttributes = args.custom_attributes;
+
+ const raw = await apsDmRequest(
+ "POST",
+ `construction/issues/v1/projects/${pid}/issues`,
+ t,
+ { body, headers: issuesWriteHeaders(region) },
+ );
+ return json(summarizeIssueDetail(raw));
+ }
+
+ // ── aps_issues_update ───────────────────────────────────────
+ if (name === "aps_issues_update") {
+ const projectId = args.project_id as string;
+ const issueId = args.issue_id as string;
+ const e1 = validateIssuesProjectId(projectId);
+ if (e1) return fail(e1);
+ const e2 = validateIssueId(issueId);
+ if (e2) return fail(e2);
+
+ const pid = toIssuesProjectId(projectId);
+ const region = args.region as string | undefined;
+ const t = await token();
+
+ const body: Record = {};
+ if (args.title != null) body.title = args.title;
+ if (args.description != null) body.description = args.description;
+ if (args.status != null) body.status = args.status;
+ if (args.assigned_to && !args.assigned_to_type)
+ return fail("assigned_to_type is required when assigned_to is provided.");
+ if (args.assigned_to != null && args.assigned_to_type != null) {
+ body.assignedTo = args.assigned_to;
+ body.assignedToType = args.assigned_to_type;
+ } else if (args.assigned_to_type != null) {
+ body.assignedToType = args.assigned_to_type;
+ }
+ if (args.due_date != null) body.dueDate = args.due_date;
+ if (args.start_date != null) body.startDate = args.start_date;
+ if (args.location_id != null) body.locationId = args.location_id;
+ if (args.location_details != null) body.locationDetails = args.location_details;
+ if (args.root_cause_id != null) body.rootCauseId = args.root_cause_id;
+ if (args.published != null) body.published = args.published;
+ if (args.watchers != null) body.watchers = args.watchers;
+ if (args.custom_attributes != null) body.customAttributes = args.custom_attributes;
+
+ if (Object.keys(body).length === 0) {
+ return fail("No fields to update. Provide at least one field to change.");
+ }
+
+ const raw = await apsDmRequest(
+ "PATCH",
+ `construction/issues/v1/projects/${pid}/issues/${issueId}`,
+ t,
+ { body, headers: issuesWriteHeaders(region) },
+ );
+ return json(summarizeIssueDetail(raw));
+ }
+
+ // ── aps_issues_get_comments ─────────────────────────────────
+ if (name === "aps_issues_get_comments") {
+ const projectId = args.project_id as string;
+ const issueId = args.issue_id as string;
+ const e1 = validateIssuesProjectId(projectId);
+ if (e1) return fail(e1);
+ const e2 = validateIssueId(issueId);
+ if (e2) return fail(e2);
+
+ const pid = toIssuesProjectId(projectId);
+ const region = args.region as string | undefined;
+ const t = await token();
+
+ const query: Record = {};
+ if (args.limit != null) query.limit = String(Math.min(Math.max(Number(args.limit) || 100, 1), 100));
+ if (args.offset != null) query.offset = String(args.offset);
+ if (args.sort_by) query.sortBy = args.sort_by as string;
+
+ const raw = await apsDmRequest(
+ "GET",
+ `construction/issues/v1/projects/${pid}/issues/${issueId}/comments`,
+ t,
+ { query, headers: issuesHeaders(region) },
+ );
+ return json(summarizeComments(raw));
+ }
+
+ // ── aps_issues_create_comment ───────────────────────────────
+ if (name === "aps_issues_create_comment") {
+ const projectId = args.project_id as string;
+ const issueId = args.issue_id as string;
+ const e1 = validateIssuesProjectId(projectId);
+ if (e1) return fail(e1);
+ const e2 = validateIssueId(issueId);
+ if (e2) return fail(e2);
+
+ const body = args.body as string;
+ if (!body) return fail("body is required.");
+
+ const pid = toIssuesProjectId(projectId);
+ const region = args.region as string | undefined;
+ const t = await token();
+
+ const raw = await apsDmRequest(
+ "POST",
+ `construction/issues/v1/projects/${pid}/issues/${issueId}/comments`,
+ t,
+ { body: { body }, headers: issuesWriteHeaders(region) },
+ );
+ return json(raw);
+ }
+
+ // ── aps_issues_docs ─────────────────────────────────────────
+ if (name === "aps_issues_docs") {
+ return ok(ISSUES_DOCS);
+ }
+
return fail(`Unknown tool: ${name}`);
}