Skip to content

Commit e7b107a

Browse files
authored
feat: Adding support to Issues
feat: Adding support to Issues Merge pull request #4 from EverseDevelopment/feature/Issues
2 parents 03abb70 + 1adf9ac commit e7b107a

File tree

6 files changed

+1492
-7
lines changed

6 files changed

+1492
-7
lines changed

manifest.json

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,20 @@
2020
"env": {
2121
"APS_CLIENT_ID": "${user_config.aps_client_id}",
2222
"APS_CLIENT_SECRET": "${user_config.aps_client_secret}",
23-
"APS_SCOPE": "${user_config.aps_scope}"
23+
"APS_SCOPE": "${user_config.aps_scope}",
24+
"APS_CALLBACK_PORT": "${user_config.aps_callback_port}"
2425
}
2526
}
2627
},
2728
"tools": [
29+
{
30+
"name": "aps_login",
31+
"description": "Start 3-legged OAuth login (opens browser). Gives user-context access to APS."
32+
},
33+
{
34+
"name": "aps_logout",
35+
"description": "Clear 3-legged session. Falls back to 2-legged (app) token."
36+
},
2837
{
2938
"name": "aps_get_token",
3039
"description": "Verify APS credentials and obtain a 2-legged access token."
@@ -60,6 +69,42 @@
6069
{
6170
"name": "aps_docs",
6271
"description": "APS Data Management quick-reference: ID formats, workflows, API paths, error troubleshooting."
72+
},
73+
{
74+
"name": "aps_issues_request",
75+
"description": "Raw ACC Issues API call (construction/issues/v1). Full JSON response. Power-user tool."
76+
},
77+
{
78+
"name": "aps_issues_get_types",
79+
"description": "Get issue categories and types (subtypes) for a project — compact summary with id, title, code."
80+
},
81+
{
82+
"name": "aps_issues_list",
83+
"description": "List/search issues with filtering by status, assignee, type, date, text. Compact summary per issue."
84+
},
85+
{
86+
"name": "aps_issues_get",
87+
"description": "Get detailed info for a single issue: title, status, assignee, dates, custom attributes, comments count."
88+
},
89+
{
90+
"name": "aps_issues_create",
91+
"description": "Create a new issue. Requires title, issueSubtypeId, status. Supports all optional fields."
92+
},
93+
{
94+
"name": "aps_issues_update",
95+
"description": "Update an existing issue. Only send the fields you want to change."
96+
},
97+
{
98+
"name": "aps_issues_get_comments",
99+
"description": "Get all comments for an issue — compact list with body, author, date."
100+
},
101+
{
102+
"name": "aps_issues_create_comment",
103+
"description": "Add a comment to an issue."
104+
},
105+
{
106+
"name": "aps_issues_docs",
107+
"description": "ACC Issues API quick-reference: project ID format, statuses, workflows, filters, error troubleshooting."
63108
}
64109
],
65110
"keywords": [
@@ -101,6 +146,13 @@
101146
"description": "Space-separated scopes (e.g. data:read or data:read data:write). Default: data:read",
102147
"default": "data:read",
103148
"required": false
149+
},
150+
"aps_callback_port": {
151+
"type": "string",
152+
"title": "3LO callback port",
153+
"description": "Localhost port for the 3-legged OAuth callback. Default: 8910",
154+
"default": "8910",
155+
"required": false
104156
}
105157
}
106158
}

scripts/pack-mcpb.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ fs.copyFileSync(path.join(root, "manifest.json"), path.join(buildDir, "manifest.
2424

2525
// Copy server entry (dist -> server/)
2626
const distDir = path.join(root, "dist");
27-
for (const name of ["index.js", "aps-auth.js", "aps-helpers.js"]) {
27+
for (const name of ["index.js", "aps-auth.js", "aps-issues-helpers.js", "aps-dm-helpers.js"]) {
2828
const src = path.join(distDir, name);
2929
if (!fs.existsSync(src)) throw new Error(`Build first: missing ${src}`);
3030
fs.copyFileSync(src, path.join(buildDir, "server", name));

src/aps-auth.ts

Lines changed: 293 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,37 @@
11
/**
2-
* Autodesk Platform Services (APS) 2-legged OAuth and API helpers.
2+
* Autodesk Platform Services (APS) OAuth helpers.
3+
*
4+
* 2‑legged (client credentials) – getApsToken()
5+
* 3‑legged (authorization code) – performAps3loLogin(), getValid3loToken(), clear3loLogin()
6+
*
37
* Supports all Data Management API endpoints per datamanagement.yaml.
48
*/
59

10+
/** Escape special characters for safe HTML embedding. */
11+
function escapeHtml(s: string): string {
12+
return s
13+
.replace(/&/g, "&")
14+
.replace(/</g, "&lt;")
15+
.replace(/>/g, "&gt;")
16+
.replace(/"/g, "&quot;")
17+
.replace(/'/g, "&#39;");
18+
}
19+
20+
import { createServer } from "node:http";
21+
import type { IncomingMessage, ServerResponse } from "node:http";
22+
import { spawn } from "node:child_process";
23+
import { homedir } from "node:os";
24+
import { join } from "node:path";
25+
import {
26+
existsSync,
27+
mkdirSync,
28+
readFileSync,
29+
writeFileSync,
30+
unlinkSync,
31+
} from "node:fs";
32+
633
const APS_TOKEN_URL = "https://developer.api.autodesk.com/authentication/v2/token";
34+
const APS_AUTHORIZE_URL = "https://developer.api.autodesk.com/authentication/v2/authorize";
735
const APS_BASE = "https://developer.api.autodesk.com";
836

937
/** Structured error thrown by APS API calls. Carries status code + body for rich error context. */
@@ -161,3 +189,267 @@ export async function apsDmRequest(
161189
}
162190
return { ok: true, status: res.status, body: text };
163191
}
192+
193+
// ── 3‑legged OAuth (authorization code + PKCE‑optional) ─────────
194+
195+
/** Shape of the 3LO token data we persist to disk. */
196+
interface Aps3loTokenData {
197+
access_token: string;
198+
refresh_token: string;
199+
expires_at: number; // epoch ms
200+
scope: string;
201+
}
202+
203+
const TOKEN_DIR = join(homedir(), ".aps-mcp");
204+
const TOKEN_FILE = join(TOKEN_DIR, "3lo-tokens.json");
205+
206+
function read3loCache(): Aps3loTokenData | null {
207+
try {
208+
if (!existsSync(TOKEN_FILE)) return null;
209+
return JSON.parse(readFileSync(TOKEN_FILE, "utf8")) as Aps3loTokenData;
210+
} catch {
211+
return null;
212+
}
213+
}
214+
215+
function write3loCache(data: Aps3loTokenData): void {
216+
if (!existsSync(TOKEN_DIR)) mkdirSync(TOKEN_DIR, { recursive: true });
217+
writeFileSync(TOKEN_FILE, JSON.stringify(data, null, 2));
218+
}
219+
220+
function deleteCacheFile(): void {
221+
try {
222+
if (existsSync(TOKEN_FILE)) unlinkSync(TOKEN_FILE);
223+
} catch {
224+
/* ignore */
225+
}
226+
}
227+
228+
/** Open a URL in the user's default browser (cross‑platform). */
229+
function openBrowser(url: string): void {
230+
let program: string;
231+
let args: string[];
232+
233+
if (process.platform === "win32") {
234+
program = "cmd";
235+
args = ["/c", "start", "", url];
236+
} else if (process.platform === "darwin") {
237+
program = "open";
238+
args = [url];
239+
} else {
240+
program = "xdg-open";
241+
args = [url];
242+
}
243+
244+
const child = spawn(program, args, { stdio: "ignore" });
245+
child.on("error", () => { /* ignore – best‑effort */ });
246+
child.unref();
247+
}
248+
249+
/** In‑memory cache so we don't re‑read the file every call. */
250+
let cached3lo: Aps3loTokenData | null = null;
251+
252+
/**
253+
* Perform the interactive 3‑legged OAuth login.
254+
*
255+
* 1. Spins up a temporary HTTP server on `callbackPort`.
256+
* 2. Opens the user's browser to the APS authorize endpoint.
257+
* 3. Waits for the redirect callback with the authorization code.
258+
* 4. Exchanges the code for access + refresh tokens.
259+
* 5. Persists tokens to `~/.aps-mcp/3lo-tokens.json`.
260+
*
261+
* Resolves when the login is complete or rejects on timeout / error.
262+
*/
263+
export async function performAps3loLogin(
264+
clientId: string,
265+
clientSecret: string,
266+
scope: string,
267+
callbackPort = 8910,
268+
): Promise<{ access_token: string; message: string }> {
269+
const redirectUri = `http://localhost:${callbackPort}/callback`;
270+
271+
return new Promise((resolve, reject) => {
272+
const server = createServer(
273+
async (req: IncomingMessage, res: ServerResponse) => {
274+
const reqUrl = new URL(req.url ?? "/", `http://localhost:${callbackPort}`);
275+
276+
if (reqUrl.pathname !== "/callback") {
277+
res.writeHead(404);
278+
res.end("Not found");
279+
return;
280+
}
281+
282+
const error = reqUrl.searchParams.get("error");
283+
if (error) {
284+
const desc = reqUrl.searchParams.get("error_description") ?? error;
285+
const safeDesc = escapeHtml(desc);
286+
res.writeHead(400, { "Content-Type": "text/html" });
287+
res.end(
288+
`<html><body><h2>Authorization failed</h2><p>${safeDesc}</p></body></html>`,
289+
);
290+
server.close();
291+
reject(new Error(`APS authorization failed: ${desc}`));
292+
return;
293+
}
294+
295+
const code = reqUrl.searchParams.get("code");
296+
if (!code) {
297+
res.writeHead(400, { "Content-Type": "text/html" });
298+
res.end(
299+
"<html><body><h2>Missing authorization code</h2></body></html>",
300+
);
301+
server.close();
302+
reject(new Error("No authorization code received in callback."));
303+
return;
304+
}
305+
306+
// Exchange the authorization code for tokens
307+
try {
308+
const tokenBody = new URLSearchParams({
309+
grant_type: "authorization_code",
310+
code,
311+
redirect_uri: redirectUri,
312+
client_id: clientId,
313+
client_secret: clientSecret,
314+
});
315+
316+
const tokenRes = await fetch(APS_TOKEN_URL, {
317+
method: "POST",
318+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
319+
body: tokenBody.toString(),
320+
});
321+
322+
if (!tokenRes.ok) {
323+
const text = await tokenRes.text();
324+
const safeText = escapeHtml(text);
325+
res.writeHead(500, { "Content-Type": "text/html" });
326+
res.end(
327+
`<html><body><h2>Token exchange failed</h2><pre>${safeText}</pre></body></html>`,
328+
);
329+
server.close();
330+
reject(
331+
new Error(`Token exchange failed (${tokenRes.status}): ${safeText}`),
332+
);
333+
return;
334+
}
335+
336+
const data = (await tokenRes.json()) as {
337+
access_token: string;
338+
refresh_token: string;
339+
expires_in: number;
340+
};
341+
342+
const cacheData: Aps3loTokenData = {
343+
access_token: data.access_token,
344+
refresh_token: data.refresh_token,
345+
expires_at: Date.now() + (data.expires_in - 60) * 1000,
346+
scope,
347+
};
348+
write3loCache(cacheData);
349+
cached3lo = cacheData;
350+
351+
res.writeHead(200, { "Content-Type": "text/html" });
352+
res.end(
353+
"<html><body><h2>Logged in to APS</h2>" +
354+
"<p>You can close this tab and return to Claude Desktop.</p></body></html>",
355+
);
356+
server.close();
357+
358+
resolve({
359+
access_token: data.access_token,
360+
message:
361+
`3-legged login successful. Tokens cached to ${TOKEN_FILE}. ` +
362+
"The token will auto-refresh when it expires.",
363+
});
364+
} catch (err) {
365+
res.writeHead(500, { "Content-Type": "text/html" });
366+
res.end(
367+
`<html><body><h2>Error</h2><pre>${String(err)}</pre></body></html>`,
368+
);
369+
server.close();
370+
reject(err);
371+
}
372+
},
373+
);
374+
375+
server.listen(callbackPort, () => {
376+
const authUrl = new URL(APS_AUTHORIZE_URL);
377+
authUrl.searchParams.set("client_id", clientId);
378+
authUrl.searchParams.set("response_type", "code");
379+
authUrl.searchParams.set("redirect_uri", redirectUri);
380+
authUrl.searchParams.set("scope", scope);
381+
openBrowser(authUrl.toString());
382+
});
383+
384+
// Give the user 2 minutes to complete login
385+
setTimeout(() => {
386+
server.close();
387+
reject(new Error("3LO login timed out after 2 minutes. Try again."));
388+
}, 120_000);
389+
});
390+
}
391+
392+
/**
393+
* Return a valid 3LO access token if one exists (from cache or by refreshing).
394+
* Returns `null` when no 3LO session is active (caller should fall back to 2LO).
395+
*/
396+
export async function getValid3loToken(
397+
clientId: string,
398+
clientSecret: string,
399+
): Promise<string | null> {
400+
if (!cached3lo) cached3lo = read3loCache();
401+
if (!cached3lo) return null;
402+
403+
// Still valid?
404+
if (cached3lo.expires_at > Date.now() + 60_000) {
405+
return cached3lo.access_token;
406+
}
407+
408+
// Attempt refresh
409+
if (cached3lo.refresh_token) {
410+
try {
411+
const body = new URLSearchParams({
412+
grant_type: "refresh_token",
413+
refresh_token: cached3lo.refresh_token,
414+
client_id: clientId,
415+
client_secret: clientSecret,
416+
scope: cached3lo.scope,
417+
});
418+
419+
const res = await fetch(APS_TOKEN_URL, {
420+
method: "POST",
421+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
422+
body: body.toString(),
423+
});
424+
425+
if (res.ok) {
426+
const data = (await res.json()) as {
427+
access_token: string;
428+
refresh_token: string;
429+
expires_in: number;
430+
};
431+
cached3lo = {
432+
access_token: data.access_token,
433+
refresh_token: data.refresh_token,
434+
expires_at: Date.now() + (data.expires_in - 60) * 1000,
435+
scope: cached3lo.scope,
436+
};
437+
write3loCache(cached3lo);
438+
return cached3lo.access_token;
439+
}
440+
} catch {
441+
// refresh failed – fall through to clear
442+
}
443+
}
444+
445+
// Expired and refresh failed
446+
deleteCacheFile();
447+
cached3lo = null;
448+
return null;
449+
}
450+
451+
/** Clear any cached 3LO tokens (in‑memory + on disk). */
452+
export function clear3loLogin(): void {
453+
deleteCacheFile();
454+
cached3lo = null;
455+
}

0 commit comments

Comments
 (0)