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}`); }