From a69c899160a236e44b8e90e039558adcdc7eab29 Mon Sep 17 00:00:00 2001 From: Pablo Derendinger <98769613+pderendinger-everse@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:50:30 -0300 Subject: [PATCH 1/7] Adding support to Issues --- manifest.json | 36 ++ src/{aps-helpers.ts => aps-dm-helpers.ts} | 0 src/aps-issues-helpers.ts | 348 +++++++++++ src/index.ts | 712 +++++++++++++++++++++- 4 files changed, 1094 insertions(+), 2 deletions(-) rename src/{aps-helpers.ts => aps-dm-helpers.ts} (100%) create mode 100644 src/aps-issues-helpers.ts diff --git a/manifest.json b/manifest.json index 4426cf8..bbb1af9 100644 --- a/manifest.json +++ b/manifest.json @@ -58,6 +58,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"], 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..c07bdf4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ /** * MCP server for Autodesk Platform Services (APS). * - * Tools: + * Data Management Tools: * aps_get_token – verify credentials / obtain 2‑legged token * aps_dm_request – raw Data Management API (power‑user) * aps_list_hubs – simplified hub listing @@ -11,6 +11,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"; @@ -34,7 +45,18 @@ 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 ────────────────────────────────────────────────── @@ -284,6 +306,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 ──────────────────────────────────────────────── @@ -442,6 +887,269 @@ 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 != null) body.assignedTo = args.assigned_to; + 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 != null) body.assignedTo = args.assigned_to; + 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(args.limit); + 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}`); } From 86dc19a54cd2c5882d6a8149830b2558571ee41f Mon Sep 17 00:00:00 2001 From: Pablo Derendinger <98769613+pderendinger-everse@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:30:02 -0300 Subject: [PATCH 2/7] Adds issue helpers and DM helpers. Adds the new helper files to the server directory. This enables new features related to issues and direct messaging. --- scripts/pack-mcpb.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)); From b93fca0377bea8b6f4bc37a0dbe360f1a230c050 Mon Sep 17 00:00:00 2001 From: Pablo Derendinger <98769613+pderendinger-everse@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:38:35 -0300 Subject: [PATCH 3/7] Validates assigned_to and assigned_to_type Ensures `assigned_to_type` is required when `assigned_to` is provided. Allows `assigned_to_type` to be set independently. --- src/index.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index c07bdf4..c4d4883 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1032,8 +1032,14 @@ async function handleTool( status, }; if (args.description != null) body.description = args.description; - if (args.assigned_to != null) body.assignedTo = args.assigned_to; - if (args.assigned_to_type != null) body.assignedToType = args.assigned_to_type; + 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; @@ -1069,8 +1075,14 @@ async function handleTool( 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 != null) body.assignedTo = args.assigned_to; - if (args.assigned_to_type != null) body.assignedToType = args.assigned_to_type; + 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; From 62f3a61018551e2625936dfc0ee6938a3c68c5e3 Mon Sep 17 00:00:00 2001 From: Pablo Derendinger <98769613+pderendinger-everse@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:57:06 -0300 Subject: [PATCH 4/7] Adds 3-legged OAuth support Implements 3-legged OAuth flow for user-context APS access. Introduces `aps_login` and `aps_logout` tools for managing user sessions. Falls back to 2-legged authentication when no 3LO session is active. --- manifest.json | 18 +++- src/aps-auth.ts | 272 +++++++++++++++++++++++++++++++++++++++++++++++- src/index.ts | 79 +++++++++++++- 3 files changed, 364 insertions(+), 5 deletions(-) diff --git a/manifest.json b/manifest.json index bbb1af9..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." @@ -125,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/src/aps-auth.ts b/src/aps-auth.ts index 2da5f92..1ac3016 100644 --- a/src/aps-auth.ts +++ b/src/aps-auth.ts @@ -1,9 +1,27 @@ /** - * Autodesk Platform Services (APS) 2-legged OAuth and API helpers. + * Autodesk Platform Services (APS) OAuth helpers. + * + * 2‑legged (client credentials) – getApsToken() + * 3‑legged (authorization code) – performAps3loLogin(), getValid3loToken(), clear3loLogin() + * * Supports all Data Management API endpoints per datamanagement.yaml. */ +import { createServer } from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { exec } from "node:child_process"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + unlinkSync, +} from "node:fs"; + const APS_TOKEN_URL = "https://developer.api.autodesk.com/authentication/v2/token"; +const APS_AUTHORIZE_URL = "https://developer.api.autodesk.com/authentication/v2/authorize"; const APS_BASE = "https://developer.api.autodesk.com"; /** Structured error thrown by APS API calls. Carries status code + body for rich error context. */ @@ -161,3 +179,255 @@ export async function apsDmRequest( } return { ok: true, status: res.status, body: text }; } + +// ── 3‑legged OAuth (authorization code + PKCE‑optional) ───────── + +/** Shape of the 3LO token data we persist to disk. */ +interface Aps3loTokenData { + access_token: string; + refresh_token: string; + expires_at: number; // epoch ms + scope: string; +} + +const TOKEN_DIR = join(homedir(), ".aps-mcp"); +const TOKEN_FILE = join(TOKEN_DIR, "3lo-tokens.json"); + +function read3loCache(): Aps3loTokenData | null { + try { + if (!existsSync(TOKEN_FILE)) return null; + return JSON.parse(readFileSync(TOKEN_FILE, "utf8")) as Aps3loTokenData; + } catch { + return null; + } +} + +function write3loCache(data: Aps3loTokenData): void { + if (!existsSync(TOKEN_DIR)) mkdirSync(TOKEN_DIR, { recursive: true }); + writeFileSync(TOKEN_FILE, JSON.stringify(data, null, 2)); +} + +function deleteCacheFile(): void { + try { + if (existsSync(TOKEN_FILE)) unlinkSync(TOKEN_FILE); + } catch { + /* ignore */ + } +} + +/** Open a URL in the user's default browser (cross‑platform). */ +function openBrowser(url: string): void { + const cmd = + process.platform === "win32" + ? `start "" "${url}"` + : process.platform === "darwin" + ? `open "${url}"` + : `xdg-open "${url}"`; + exec(cmd); +} + +/** In‑memory cache so we don't re‑read the file every call. */ +let cached3lo: Aps3loTokenData | null = null; + +/** + * Perform the interactive 3‑legged OAuth login. + * + * 1. Spins up a temporary HTTP server on `callbackPort`. + * 2. Opens the user's browser to the APS authorize endpoint. + * 3. Waits for the redirect callback with the authorization code. + * 4. Exchanges the code for access + refresh tokens. + * 5. Persists tokens to `~/.aps-mcp/3lo-tokens.json`. + * + * Resolves when the login is complete or rejects on timeout / error. + */ +export async function performAps3loLogin( + clientId: string, + clientSecret: string, + scope: string, + callbackPort = 8910, +): Promise<{ access_token: string; message: string }> { + const redirectUri = `http://localhost:${callbackPort}/callback`; + + return new Promise((resolve, reject) => { + const server = createServer( + async (req: IncomingMessage, res: ServerResponse) => { + const reqUrl = new URL(req.url ?? "/", `http://localhost:${callbackPort}`); + + if (reqUrl.pathname !== "/callback") { + res.writeHead(404); + res.end("Not found"); + return; + } + + const error = reqUrl.searchParams.get("error"); + if (error) { + const desc = reqUrl.searchParams.get("error_description") ?? error; + res.writeHead(400, { "Content-Type": "text/html" }); + res.end( + `

Authorization failed

${desc}

`, + ); + 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(); + res.writeHead(500, { "Content-Type": "text/html" }); + res.end( + `

Token exchange failed

${text}
`, + ); + server.close(); + reject( + new Error(`Token exchange failed (${tokenRes.status}): ${text}`), + ); + 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/index.ts b/src/index.ts index c4d4883..40539dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,12 @@ /** * MCP server for Autodesk Platform Services (APS). * - * Data Management 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 @@ -30,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, @@ -63,6 +74,7 @@ import { 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) { @@ -72,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); } @@ -101,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", @@ -737,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(); From 2ceb0791b41a50b4bf3a1d0f9391263efa51f855 Mon Sep 17 00:00:00 2001 From: Pablo Derendinger <98769613+pderendinger-everse@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:30:48 -0300 Subject: [PATCH 5/7] Sanitizes error messages and limits list size Ensures error messages displayed to the user are properly escaped to prevent potential HTML injection vulnerabilities. Limits the number of items listed in the tool's output to a maximum of 100 to avoid performance issues when retrieving large datasets. It will always be greater or equal to 1 --- src/aps-auth.ts | 13 ++++++++++++- src/index.ts | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/aps-auth.ts b/src/aps-auth.ts index 1ac3016..dac8541 100644 --- a/src/aps-auth.ts +++ b/src/aps-auth.ts @@ -7,6 +7,16 @@ * 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 { exec } from "node:child_process"; @@ -262,9 +272,10 @@ export async function performAps3loLogin( 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

${desc}

`, + `

Authorization failed

${safeDesc}

`, ); server.close(); reject(new Error(`APS authorization failed: ${desc}`)); diff --git a/src/index.ts b/src/index.ts index 40539dd..56bb506 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1192,7 +1192,7 @@ async function handleTool( const t = await token(); const query: Record = {}; - if (args.limit != null) query.limit = String(args.limit); + 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; From ead324b7a05394bd4e0051bf60564b1edd3a64b2 Mon Sep 17 00:00:00 2001 From: Pablo Derendinger <98769613+pderendinger-everse@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:39:16 -0300 Subject: [PATCH 6/7] Sanitizes token exchange error messages Sanitizes the error message displayed to the user and included in the error object when the token exchange fails. This prevents potential cross-site scripting (XSS) vulnerabilities by escaping HTML entities in the error text. --- src/aps-auth.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/aps-auth.ts b/src/aps-auth.ts index dac8541..906883b 100644 --- a/src/aps-auth.ts +++ b/src/aps-auth.ts @@ -311,13 +311,14 @@ export async function performAps3loLogin( if (!tokenRes.ok) { const text = await tokenRes.text(); + const safeText = escapeHtml(text); res.writeHead(500, { "Content-Type": "text/html" }); res.end( - `

Token exchange failed

${text}
`, + `

Token exchange failed

${safeText}
`, ); server.close(); reject( - new Error(`Token exchange failed (${tokenRes.status}): ${text}`), + new Error(`Token exchange failed (${tokenRes.status}): ${safeText}`), ); return; } From 1adf9acfe2810f3735e290d90e40ec1a3256226d Mon Sep 17 00:00:00 2001 From: Pablo Derendinger <98769613+pderendinger-everse@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:45:48 -0300 Subject: [PATCH 7/7] Uses spawn instead of exec for opening browser Replaces `exec` with `spawn` for opening URLs in the default browser to avoid potential command injection vulnerabilities and improve reliability. The previous implementation used `exec`, which could be vulnerable if the URL contained malicious characters. `spawn` offers better control over the process and avoids shell interpretation. --- src/aps-auth.ts | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/aps-auth.ts b/src/aps-auth.ts index 906883b..81a7b04 100644 --- a/src/aps-auth.ts +++ b/src/aps-auth.ts @@ -19,7 +19,7 @@ function escapeHtml(s: string): string { import { createServer } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http"; -import { exec } from "node:child_process"; +import { spawn } from "node:child_process"; import { homedir } from "node:os"; import { join } from "node:path"; import { @@ -227,13 +227,23 @@ function deleteCacheFile(): void { /** Open a URL in the user's default browser (cross‑platform). */ function openBrowser(url: string): void { - const cmd = - process.platform === "win32" - ? `start "" "${url}"` - : process.platform === "darwin" - ? `open "${url}"` - : `xdg-open "${url}"`; - exec(cmd); + 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. */