@k-system/tickr-mcp 1.21.0 → 1.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-client.d.ts +4 -0
- package/dist/api-client.js +63 -8
- package/dist/auth.d.ts +9 -2
- package/dist/auth.js +61 -5
- package/dist/config.d.ts +4 -0
- package/dist/config.js +27 -3
- package/dist/device-grant.d.ts +53 -0
- package/dist/device-grant.js +194 -0
- package/dist/server.js +24 -3
- package/dist/tools/add-epic-dependency.d.ts +4 -0
- package/dist/tools/add-epic-dependency.js +23 -0
- package/dist/tools/list-epics.js +1 -1
- package/dist/tools/remove-epic-dependency.d.ts +4 -0
- package/dist/tools/remove-epic-dependency.js +22 -0
- package/dist/types.d.ts +2 -2
- package/package.json +1 -1
package/dist/api-client.d.ts
CHANGED
|
@@ -24,6 +24,10 @@ export declare class ApiClient {
|
|
|
24
24
|
patch<T = unknown>(path: string, body?: unknown): Promise<T>;
|
|
25
25
|
put<T = unknown>(path: string, body?: unknown): Promise<T>;
|
|
26
26
|
delete<T = unknown>(path: string): Promise<T>;
|
|
27
|
+
/** Health check — vrací true pokud API odpovídá */
|
|
28
|
+
healthCheck(): Promise<boolean>;
|
|
27
29
|
private request;
|
|
30
|
+
private shouldRetry;
|
|
31
|
+
private getRetryDelay;
|
|
28
32
|
}
|
|
29
33
|
//# sourceMappingURL=api-client.d.ts.map
|
package/dist/api-client.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { getAuthHeader } from "./auth.js";
|
|
2
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
3
|
+
const MAX_RETRIES = 3;
|
|
4
|
+
const RETRY_BASE_MS = 200;
|
|
2
5
|
/** Fetch wrapper pro Tickr REST API s automatickou autentizací */
|
|
3
6
|
export class ApiClient {
|
|
4
7
|
config;
|
|
@@ -20,6 +23,20 @@ export class ApiClient {
|
|
|
20
23
|
async delete(path) {
|
|
21
24
|
return this.request("DELETE", path);
|
|
22
25
|
}
|
|
26
|
+
/** Health check — vrací true pokud API odpovídá */
|
|
27
|
+
async healthCheck() {
|
|
28
|
+
try {
|
|
29
|
+
const url = `${this.config.apiUrl}/health`;
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const timeout = setTimeout(() => controller.abort(), 5_000);
|
|
32
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
33
|
+
clearTimeout(timeout);
|
|
34
|
+
return res.ok;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
23
40
|
async request(method, path, body, retryCount = 0) {
|
|
24
41
|
const auth = await getAuthHeader(this.config);
|
|
25
42
|
const url = `${this.config.apiUrl}${path}`;
|
|
@@ -30,14 +47,38 @@ export class ApiClient {
|
|
|
30
47
|
if (body !== undefined) {
|
|
31
48
|
headers["Content-Type"] = "application/json";
|
|
32
49
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
50
|
+
// Fetch s timeout přes AbortController
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
53
|
+
let res;
|
|
54
|
+
try {
|
|
55
|
+
res = await fetch(url, {
|
|
56
|
+
method,
|
|
57
|
+
headers,
|
|
58
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
59
|
+
signal: controller.signal,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
clearTimeout(timeout);
|
|
64
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
65
|
+
throw new Error(`Request timeout after ${REQUEST_TIMEOUT_MS / 1000}s: ${method} ${path}`);
|
|
66
|
+
}
|
|
67
|
+
// Síťová chyba — retry pokud máme pokusy
|
|
68
|
+
if (retryCount < MAX_RETRIES) {
|
|
69
|
+
const delay = RETRY_BASE_MS * Math.pow(2, retryCount);
|
|
70
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
71
|
+
return this.request(method, path, body, retryCount + 1);
|
|
72
|
+
}
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
clearTimeout(timeout);
|
|
77
|
+
}
|
|
78
|
+
// Auto-retry na 409 Conflict, 429 Too Many Requests, 5xx — max 3 pokusy
|
|
79
|
+
if (this.shouldRetry(res.status) && retryCount < MAX_RETRIES) {
|
|
80
|
+
const delay = this.getRetryDelay(res, retryCount);
|
|
81
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
41
82
|
return this.request(method, path, body, retryCount + 1);
|
|
42
83
|
}
|
|
43
84
|
if (!res.ok) {
|
|
@@ -66,5 +107,19 @@ export class ApiClient {
|
|
|
66
107
|
}
|
|
67
108
|
return json.data;
|
|
68
109
|
}
|
|
110
|
+
shouldRetry(status) {
|
|
111
|
+
return status === 409 || status === 429 || status >= 500;
|
|
112
|
+
}
|
|
113
|
+
getRetryDelay(res, retryCount) {
|
|
114
|
+
// Respektovat Retry-After header pokud existuje (429)
|
|
115
|
+
const retryAfter = res.headers.get("Retry-After");
|
|
116
|
+
if (retryAfter) {
|
|
117
|
+
const seconds = parseInt(retryAfter, 10);
|
|
118
|
+
if (!isNaN(seconds))
|
|
119
|
+
return seconds * 1000;
|
|
120
|
+
}
|
|
121
|
+
// Exponential backoff: 200ms, 400ms, 800ms
|
|
122
|
+
return RETRY_BASE_MS * Math.pow(2, retryCount);
|
|
123
|
+
}
|
|
69
124
|
}
|
|
70
125
|
//# sourceMappingURL=api-client.js.map
|
package/dist/auth.d.ts
CHANGED
|
@@ -10,9 +10,16 @@ export declare function login(config: TickrConfig, username: string, password: s
|
|
|
10
10
|
export declare function refreshToken(config: TickrConfig): Promise<TokenPair>;
|
|
11
11
|
/**
|
|
12
12
|
* Vrátí Authorization header value.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* Priorita:
|
|
14
|
+
* 1. API key (tkr_...) — přímé použití, žádná expiry
|
|
15
|
+
* 2. Device Grant session (~/.tickr/sessions/) — auto-refresh
|
|
16
|
+
* 3. JWT token (login/password) — auto-refresh
|
|
15
17
|
*/
|
|
16
18
|
export declare function getAuthHeader(config: TickrConfig): Promise<string>;
|
|
19
|
+
/**
|
|
20
|
+
* Inicializace device grant session při startu serveru.
|
|
21
|
+
* Načte existující session z disku — pokud existuje a je platná, nastaví cache.
|
|
22
|
+
*/
|
|
23
|
+
export declare function initDeviceGrantSession(config: TickrConfig): void;
|
|
17
24
|
export {};
|
|
18
25
|
//# sourceMappingURL=auth.d.ts.map
|
package/dist/auth.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { getDeviceGrantToken, loadSession, refreshAccessToken, saveSession, } from "./device-grant.js";
|
|
1
2
|
let currentTokens = null;
|
|
3
|
+
// Aktuální device grant session (pokud je aktivní)
|
|
4
|
+
let activeSession = null;
|
|
2
5
|
/** Přihlášení přes username/password — vrátí JWT token pair */
|
|
3
6
|
export async function login(config, username, password) {
|
|
4
7
|
const res = await fetch(`${config.apiUrl}/api/auth/login`, {
|
|
@@ -40,21 +43,74 @@ export async function refreshToken(config) {
|
|
|
40
43
|
}
|
|
41
44
|
/**
|
|
42
45
|
* Vrátí Authorization header value.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
46
|
+
* Priorita:
|
|
47
|
+
* 1. API key (tkr_...) — přímé použití, žádná expiry
|
|
48
|
+
* 2. Device Grant session (~/.tickr/sessions/) — auto-refresh
|
|
49
|
+
* 3. JWT token (login/password) — auto-refresh
|
|
45
50
|
*/
|
|
46
51
|
export async function getAuthHeader(config) {
|
|
47
|
-
// API key auth — jednoduché, bez expiry
|
|
52
|
+
// 1. API key auth — jednoduché, bez expiry
|
|
48
53
|
if (config.apiKey) {
|
|
49
54
|
return `Bearer ${config.apiKey}`;
|
|
50
55
|
}
|
|
51
|
-
//
|
|
56
|
+
// 2. Device Grant session — auto-refresh
|
|
57
|
+
if (config.tenantId && config.agentName) {
|
|
58
|
+
return `Bearer ${await getDeviceGrantAuthToken(config)}`;
|
|
59
|
+
}
|
|
60
|
+
// 3. JWT auth — refresh pokud token brzy vyprší (30s buffer)
|
|
52
61
|
if (currentTokens && currentTokens.expiresAt - Date.now() < 30_000) {
|
|
53
62
|
await refreshToken(config);
|
|
54
63
|
}
|
|
55
64
|
if (!currentTokens) {
|
|
56
|
-
throw new Error("Not authenticated — set TICKR_API_KEY or
|
|
65
|
+
throw new Error("Not authenticated — set TICKR_API_KEY or configure device grant");
|
|
57
66
|
}
|
|
58
67
|
return `Bearer ${currentTokens.accessToken}`;
|
|
59
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Device Grant auth token s auto-refresh a auto-pairing.
|
|
71
|
+
* Pokud session neexistuje, spustí interaktivní pairing flow.
|
|
72
|
+
*/
|
|
73
|
+
async function getDeviceGrantAuthToken(config) {
|
|
74
|
+
// Zkus cache
|
|
75
|
+
if (activeSession && activeSession.expiresAt - Date.now() > 30_000) {
|
|
76
|
+
return activeSession.accessToken;
|
|
77
|
+
}
|
|
78
|
+
// Zkus refresh existující session
|
|
79
|
+
if (activeSession && activeSession.refreshToken) {
|
|
80
|
+
try {
|
|
81
|
+
const result = await refreshAccessToken(config, activeSession.refreshToken);
|
|
82
|
+
activeSession = {
|
|
83
|
+
accessToken: result.access_token,
|
|
84
|
+
refreshToken: result.refresh_token,
|
|
85
|
+
expiresAt: Date.now() + result.expires_in * 1000,
|
|
86
|
+
agentIdentity: result.agent_identity,
|
|
87
|
+
apiUrl: config.apiUrl,
|
|
88
|
+
tenantId: config.tenantId,
|
|
89
|
+
};
|
|
90
|
+
saveSession(config.agentName, activeSession);
|
|
91
|
+
return activeSession.accessToken;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
activeSession = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Načti z disku nebo spusť pairing
|
|
98
|
+
const token = await getDeviceGrantToken(config, config.agentName, config.tenantId);
|
|
99
|
+
// Aktualizuj cache
|
|
100
|
+
activeSession = loadSession(config.agentName);
|
|
101
|
+
return token;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Inicializace device grant session při startu serveru.
|
|
105
|
+
* Načte existující session z disku — pokud existuje a je platná, nastaví cache.
|
|
106
|
+
*/
|
|
107
|
+
export function initDeviceGrantSession(config) {
|
|
108
|
+
if (!config.agentName || !config.tenantId)
|
|
109
|
+
return;
|
|
110
|
+
const session = loadSession(config.agentName);
|
|
111
|
+
if (session) {
|
|
112
|
+
activeSession = session;
|
|
113
|
+
console.error(`[tickr] ✓ Connected as ${session.agentIdentity}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
60
116
|
//# sourceMappingURL=auth.js.map
|
package/dist/config.d.ts
CHANGED
|
@@ -2,6 +2,10 @@ export interface TickrConfig {
|
|
|
2
2
|
apiUrl: string;
|
|
3
3
|
apiKey?: string;
|
|
4
4
|
defaultProject?: string;
|
|
5
|
+
/** Tenant ID pro device grant auth (UUID) */
|
|
6
|
+
tenantId?: string;
|
|
7
|
+
/** Jméno agenta pro device grant session storage */
|
|
8
|
+
agentName?: string;
|
|
5
9
|
}
|
|
6
10
|
/** Načte konfiguraci z env vars nebo ~/.tickr/config.json */
|
|
7
11
|
export declare function loadConfig(): TickrConfig;
|
package/dist/config.js
CHANGED
|
@@ -6,28 +6,52 @@ export function loadConfig() {
|
|
|
6
6
|
const apiUrl = process.env.TICKR_API_URL;
|
|
7
7
|
const apiKey = process.env.TICKR_API_KEY;
|
|
8
8
|
const defaultProject = process.env.TICKR_DEFAULT_PROJECT;
|
|
9
|
+
const tenantId = process.env.TICKR_TENANT_ID;
|
|
10
|
+
const agentName = process.env.TICKR_AGENT_NAME;
|
|
9
11
|
// Env vars mají prioritu
|
|
10
12
|
if (apiUrl && apiKey) {
|
|
11
|
-
|
|
13
|
+
const config = { apiUrl, apiKey, defaultProject, tenantId, agentName };
|
|
14
|
+
validateConfig(config);
|
|
15
|
+
return config;
|
|
12
16
|
}
|
|
13
17
|
// Fallback na config soubor
|
|
14
18
|
const configPath = join(homedir(), ".tickr", "config.json");
|
|
15
19
|
try {
|
|
16
20
|
const raw = readFileSync(configPath, "utf-8");
|
|
17
21
|
const file = JSON.parse(raw);
|
|
18
|
-
|
|
22
|
+
const config = {
|
|
19
23
|
apiUrl: apiUrl || file.apiUrl || "https://localhost:6001",
|
|
20
24
|
apiKey: apiKey || file.apiKey || "",
|
|
21
25
|
defaultProject: defaultProject || file.defaultProject,
|
|
26
|
+
tenantId: tenantId || file.tenantId,
|
|
27
|
+
agentName: agentName || file.agentName,
|
|
22
28
|
};
|
|
29
|
+
validateConfig(config);
|
|
30
|
+
return config;
|
|
23
31
|
}
|
|
24
32
|
catch {
|
|
25
33
|
// Config soubor neexistuje — použijeme defaults
|
|
26
|
-
|
|
34
|
+
const config = {
|
|
27
35
|
apiUrl: apiUrl || "https://localhost:6001",
|
|
28
36
|
apiKey: apiKey || "",
|
|
29
37
|
defaultProject,
|
|
38
|
+
tenantId,
|
|
39
|
+
agentName,
|
|
30
40
|
};
|
|
41
|
+
validateConfig(config);
|
|
42
|
+
return config;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Validace konfigurace — loguje varování pro problematické hodnoty */
|
|
46
|
+
function validateConfig(config) {
|
|
47
|
+
if (!config.apiKey && !config.tenantId) {
|
|
48
|
+
console.error("[tickr-mcp] WARNING: No API key or tenant ID configured — set TICKR_API_KEY or TICKR_TENANT_ID + TICKR_AGENT_NAME for device grant");
|
|
49
|
+
}
|
|
50
|
+
if (config.apiUrl && !config.apiUrl.startsWith("http://") && !config.apiUrl.startsWith("https://")) {
|
|
51
|
+
console.error(`[tickr-mcp] WARNING: apiUrl "${config.apiUrl}" doesn't start with http(s)://`);
|
|
52
|
+
}
|
|
53
|
+
if (config.apiUrl?.endsWith("/")) {
|
|
54
|
+
config.apiUrl = config.apiUrl.replace(/\/+$/, "");
|
|
31
55
|
}
|
|
32
56
|
}
|
|
33
57
|
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { TickrConfig } from "./config.js";
|
|
2
|
+
interface DeviceCodeResponse {
|
|
3
|
+
device_code: string;
|
|
4
|
+
user_code: string;
|
|
5
|
+
verification_uri: string;
|
|
6
|
+
interval: number;
|
|
7
|
+
expires_in: number;
|
|
8
|
+
}
|
|
9
|
+
interface TokenResponse {
|
|
10
|
+
access_token: string;
|
|
11
|
+
token_type: string;
|
|
12
|
+
expires_in: number;
|
|
13
|
+
refresh_token: string;
|
|
14
|
+
agent_identity: string;
|
|
15
|
+
}
|
|
16
|
+
export interface StoredSession {
|
|
17
|
+
accessToken: string;
|
|
18
|
+
refreshToken: string;
|
|
19
|
+
expiresAt: number;
|
|
20
|
+
agentIdentity: string;
|
|
21
|
+
apiUrl: string;
|
|
22
|
+
tenantId: string;
|
|
23
|
+
}
|
|
24
|
+
/** Načte uloženou session — vrátí null pokud neexistuje nebo je poškozená */
|
|
25
|
+
export declare function loadSession(agentName: string): StoredSession | null;
|
|
26
|
+
/** Uloží session na disk */
|
|
27
|
+
export declare function saveSession(agentName: string, session: StoredSession): void;
|
|
28
|
+
/** Smaže uloženou session */
|
|
29
|
+
export declare function clearSession(agentName: string): void;
|
|
30
|
+
/**
|
|
31
|
+
* Zahájí device authorization flow — vrátí device code a user code.
|
|
32
|
+
* Agent zobrazí user code v terminálu, člověk ho zadá v browseru.
|
|
33
|
+
*/
|
|
34
|
+
export declare function initiateDeviceGrant(config: TickrConfig, tenantId: string): Promise<DeviceCodeResponse>;
|
|
35
|
+
/**
|
|
36
|
+
* Polluje /oauth/token dokud člověk neschválí párování.
|
|
37
|
+
* Vrátí access + refresh token, nebo vyhodí chybu při expiraci/zamítnutí.
|
|
38
|
+
*/
|
|
39
|
+
export declare function pollForDeviceGrant(config: TickrConfig, deviceCode: string, interval: number, expiresIn: number, onStatus?: (message: string) => void): Promise<TokenResponse>;
|
|
40
|
+
/**
|
|
41
|
+
* Obnoví access token přes refresh token grant.
|
|
42
|
+
* Vrátí nový token pár (rotace — starý refresh token je zneplatněn).
|
|
43
|
+
*/
|
|
44
|
+
export declare function refreshAccessToken(config: TickrConfig, refreshToken: string): Promise<TokenResponse>;
|
|
45
|
+
/**
|
|
46
|
+
* Hlavní entry point: získej platný access token.
|
|
47
|
+
* 1. Pokud existuje uložená session a token je platný → vrať
|
|
48
|
+
* 2. Pokud token expiruje → auto-refresh
|
|
49
|
+
* 3. Pokud refresh selže → spusť device grant flow
|
|
50
|
+
*/
|
|
51
|
+
export declare function getDeviceGrantToken(config: TickrConfig, agentName: string, tenantId: string, onPairingRequired?: (userCode: string, verificationUri: string) => void): Promise<string>;
|
|
52
|
+
export {};
|
|
53
|
+
//# sourceMappingURL=device-grant.d.ts.map
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
// --- Session storage ---
|
|
5
|
+
const SESSIONS_DIR = join(homedir(), ".tickr", "sessions");
|
|
6
|
+
function ensureSessionsDir() {
|
|
7
|
+
if (!existsSync(SESSIONS_DIR)) {
|
|
8
|
+
mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function sessionPath(agentName) {
|
|
12
|
+
const safe = agentName.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
13
|
+
return join(SESSIONS_DIR, `${safe}.json`);
|
|
14
|
+
}
|
|
15
|
+
/** Načte uloženou session — vrátí null pokud neexistuje nebo je poškozená */
|
|
16
|
+
export function loadSession(agentName) {
|
|
17
|
+
try {
|
|
18
|
+
const path = sessionPath(agentName);
|
|
19
|
+
if (!existsSync(path))
|
|
20
|
+
return null;
|
|
21
|
+
const raw = readFileSync(path, "utf-8");
|
|
22
|
+
return JSON.parse(raw);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Uloží session na disk */
|
|
29
|
+
export function saveSession(agentName, session) {
|
|
30
|
+
ensureSessionsDir();
|
|
31
|
+
const path = sessionPath(agentName);
|
|
32
|
+
writeFileSync(path, JSON.stringify(session, null, 2), { mode: 0o600 });
|
|
33
|
+
}
|
|
34
|
+
/** Smaže uloženou session */
|
|
35
|
+
export function clearSession(agentName) {
|
|
36
|
+
try {
|
|
37
|
+
const path = sessionPath(agentName);
|
|
38
|
+
if (existsSync(path)) {
|
|
39
|
+
const { unlinkSync } = require("node:fs");
|
|
40
|
+
unlinkSync(path);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Ignoruj
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// --- Device Authorization Grant flow ---
|
|
48
|
+
/**
|
|
49
|
+
* Zahájí device authorization flow — vrátí device code a user code.
|
|
50
|
+
* Agent zobrazí user code v terminálu, člověk ho zadá v browseru.
|
|
51
|
+
*/
|
|
52
|
+
export async function initiateDeviceGrant(config, tenantId) {
|
|
53
|
+
const res = await fetch(`${config.apiUrl}/oauth/device/authorize`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "Content-Type": "application/json" },
|
|
56
|
+
body: JSON.stringify({ clientId: "claude-agent", tenantId }),
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
const body = await res.text();
|
|
60
|
+
throw new Error(`Device authorization failed: ${res.status} — ${body}`);
|
|
61
|
+
}
|
|
62
|
+
return (await res.json()).data;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Polluje /oauth/token dokud člověk neschválí párování.
|
|
66
|
+
* Vrátí access + refresh token, nebo vyhodí chybu při expiraci/zamítnutí.
|
|
67
|
+
*/
|
|
68
|
+
export async function pollForDeviceGrant(config, deviceCode, interval, expiresIn, onStatus) {
|
|
69
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
70
|
+
while (Date.now() < deadline) {
|
|
71
|
+
await sleep(interval * 1000);
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(`${config.apiUrl}/oauth/token`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "Content-Type": "application/json" },
|
|
76
|
+
body: JSON.stringify({
|
|
77
|
+
grantType: "urn:ietf:params:oauth:grant-type:device_code",
|
|
78
|
+
deviceCode,
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
if (res.ok) {
|
|
82
|
+
return (await res.json());
|
|
83
|
+
}
|
|
84
|
+
const error = (await res.json());
|
|
85
|
+
if (error.error === "authorization_pending") {
|
|
86
|
+
onStatus?.("Waiting for approval...");
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (error.error === "slow_down") {
|
|
90
|
+
interval = Math.min(interval + 1, 15);
|
|
91
|
+
onStatus?.("Slowing down polling...");
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (error.error === "expired_token") {
|
|
95
|
+
throw new Error("Device code expired — please try pairing again");
|
|
96
|
+
}
|
|
97
|
+
if (error.error === "access_denied") {
|
|
98
|
+
throw new Error("Pairing denied by user");
|
|
99
|
+
}
|
|
100
|
+
throw new Error(`Token exchange error: ${error.error} — ${error.error_description}`);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (err instanceof Error && (err.message.includes("expired") || err.message.includes("denied"))) {
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
// Síťová chyba — pokračuj v pollingu
|
|
107
|
+
onStatus?.("Connection error, retrying...");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
throw new Error("Device code expired — please try pairing again");
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Obnoví access token přes refresh token grant.
|
|
114
|
+
* Vrátí nový token pár (rotace — starý refresh token je zneplatněn).
|
|
115
|
+
*/
|
|
116
|
+
export async function refreshAccessToken(config, refreshToken) {
|
|
117
|
+
const res = await fetch(`${config.apiUrl}/oauth/token`, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { "Content-Type": "application/json" },
|
|
120
|
+
body: JSON.stringify({
|
|
121
|
+
grantType: "refresh_token",
|
|
122
|
+
refreshToken,
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
if (!res.ok) {
|
|
126
|
+
const body = await res.text();
|
|
127
|
+
throw new Error(`Token refresh failed: ${res.status} — ${body}`);
|
|
128
|
+
}
|
|
129
|
+
return (await res.json());
|
|
130
|
+
}
|
|
131
|
+
// --- Integrační funkce ---
|
|
132
|
+
/**
|
|
133
|
+
* Hlavní entry point: získej platný access token.
|
|
134
|
+
* 1. Pokud existuje uložená session a token je platný → vrať
|
|
135
|
+
* 2. Pokud token expiruje → auto-refresh
|
|
136
|
+
* 3. Pokud refresh selže → spusť device grant flow
|
|
137
|
+
*/
|
|
138
|
+
export async function getDeviceGrantToken(config, agentName, tenantId, onPairingRequired) {
|
|
139
|
+
// Zkus načíst existující session
|
|
140
|
+
let session = loadSession(agentName);
|
|
141
|
+
if (session) {
|
|
142
|
+
// Token ještě platný (30s buffer)
|
|
143
|
+
if (session.expiresAt - Date.now() > 30_000) {
|
|
144
|
+
return session.accessToken;
|
|
145
|
+
}
|
|
146
|
+
// Zkus refresh
|
|
147
|
+
try {
|
|
148
|
+
const result = await refreshAccessToken(config, session.refreshToken);
|
|
149
|
+
session = {
|
|
150
|
+
accessToken: result.access_token,
|
|
151
|
+
refreshToken: result.refresh_token,
|
|
152
|
+
expiresAt: Date.now() + result.expires_in * 1000,
|
|
153
|
+
agentIdentity: result.agent_identity,
|
|
154
|
+
apiUrl: config.apiUrl,
|
|
155
|
+
tenantId,
|
|
156
|
+
};
|
|
157
|
+
saveSession(agentName, session);
|
|
158
|
+
return session.accessToken;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// Refresh selhal — session expirovala, potřebujeme nové párování
|
|
162
|
+
clearSession(agentName);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Žádná platná session — spusť device grant flow
|
|
166
|
+
const deviceResponse = await initiateDeviceGrant(config, tenantId);
|
|
167
|
+
// Oznám uživateli
|
|
168
|
+
if (onPairingRequired) {
|
|
169
|
+
onPairingRequired(deviceResponse.user_code, deviceResponse.verification_uri);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
console.error(`[tickr] Agent is not paired.`);
|
|
173
|
+
console.error(`[tickr] Open ${deviceResponse.verification_uri}`);
|
|
174
|
+
console.error(`[tickr] Enter code: ${deviceResponse.user_code}`);
|
|
175
|
+
console.error(`[tickr] Waiting for approval... (expires in ${Math.round(deviceResponse.expires_in / 60)} min)`);
|
|
176
|
+
}
|
|
177
|
+
const tokenResponse = await pollForDeviceGrant(config, deviceResponse.device_code, deviceResponse.interval, deviceResponse.expires_in, (msg) => console.error(`[tickr] ${msg}`));
|
|
178
|
+
// Ulož session
|
|
179
|
+
const newSession = {
|
|
180
|
+
accessToken: tokenResponse.access_token,
|
|
181
|
+
refreshToken: tokenResponse.refresh_token,
|
|
182
|
+
expiresAt: Date.now() + tokenResponse.expires_in * 1000,
|
|
183
|
+
agentIdentity: tokenResponse.agent_identity,
|
|
184
|
+
apiUrl: config.apiUrl,
|
|
185
|
+
tenantId,
|
|
186
|
+
};
|
|
187
|
+
saveSession(agentName, newSession);
|
|
188
|
+
console.error(`[tickr] ✓ Paired as ${tokenResponse.agent_identity}`);
|
|
189
|
+
return newSession.accessToken;
|
|
190
|
+
}
|
|
191
|
+
function sleep(ms) {
|
|
192
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
193
|
+
}
|
|
194
|
+
//# sourceMappingURL=device-grant.js.map
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { loadConfig } from "./config.js";
|
|
4
|
+
import { initDeviceGrantSession } from "./auth.js";
|
|
4
5
|
import { ApiClient } from "./api-client.js";
|
|
5
6
|
import { debugLog, isDebugEnabled } from "./debug-logger.js";
|
|
6
7
|
// Tools
|
|
@@ -72,6 +73,9 @@ import { registerUpdateCycle } from "./tools/update-cycle.js";
|
|
|
72
73
|
import { registerDeleteCycle } from "./tools/delete-cycle.js";
|
|
73
74
|
import { registerUpdateEpic } from "./tools/update-epic.js";
|
|
74
75
|
import { registerDeleteEpic } from "./tools/delete-epic.js";
|
|
76
|
+
// ADR-0050: Epic Dependencies
|
|
77
|
+
import { registerAddEpicDependency } from "./tools/add-epic-dependency.js";
|
|
78
|
+
import { registerRemoveEpicDependency } from "./tools/remove-epic-dependency.js";
|
|
75
79
|
// ADR-0026 Phase 2f: Project Members
|
|
76
80
|
import { registerListProjectMembers } from "./tools/list-project-members.js";
|
|
77
81
|
import { registerAddProjectMember } from "./tools/add-project-member.js";
|
|
@@ -95,10 +99,20 @@ import { registerPendingTasksResource } from "./resources/pending-tasks-resource
|
|
|
95
99
|
import { initSignalRClient, stopSignalRClient } from "./signalr-client.js";
|
|
96
100
|
export async function startServer() {
|
|
97
101
|
const config = loadConfig();
|
|
102
|
+
// Inicializace device grant session (pokud je konfigurována)
|
|
103
|
+
initDeviceGrantSession(config);
|
|
98
104
|
const api = new ApiClient(config);
|
|
105
|
+
// Startup health check — ověření dostupnosti API
|
|
106
|
+
const healthy = await api.healthCheck();
|
|
107
|
+
if (healthy) {
|
|
108
|
+
console.error(`[tickr-mcp] API reachable at ${config.apiUrl}`);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
console.error(`[tickr-mcp] WARNING: API unreachable at ${config.apiUrl} — tools may fail`);
|
|
112
|
+
}
|
|
99
113
|
const server = new McpServer({
|
|
100
114
|
name: "tickr",
|
|
101
|
-
version: "
|
|
115
|
+
version: "1.23.0",
|
|
102
116
|
});
|
|
103
117
|
// Debug logging wrapper (dedup odstraněn — nefunkční cross-process, řeší se na API straně: TKR-ADR-0043)
|
|
104
118
|
{
|
|
@@ -195,6 +209,9 @@ export async function startServer() {
|
|
|
195
209
|
registerDeleteCycle(server, api);
|
|
196
210
|
registerUpdateEpic(server, api);
|
|
197
211
|
registerDeleteEpic(server, api);
|
|
212
|
+
// ADR-0050: Epic Dependencies
|
|
213
|
+
registerAddEpicDependency(server, api);
|
|
214
|
+
registerRemoveEpicDependency(server, api);
|
|
198
215
|
// ADR-0026 Phase 2f: Project Members
|
|
199
216
|
registerListProjectMembers(server, api);
|
|
200
217
|
registerAddProjectMember(server, api);
|
|
@@ -221,10 +238,14 @@ export async function startServer() {
|
|
|
221
238
|
// Neni kriticke — MCP server funguje i bez SignalR
|
|
222
239
|
initSignalRClient(config, server, config.defaultProject).catch(() => { });
|
|
223
240
|
// Initial heartbeat — nastav agenta jako idle
|
|
224
|
-
api.post("/api/dev-queue/heartbeat", {}).catch(() => {
|
|
241
|
+
api.post("/api/dev-queue/heartbeat", {}).catch((err) => {
|
|
242
|
+
console.error("[tickr-mcp] initial heartbeat failed:", err instanceof Error ? err.message : String(err));
|
|
243
|
+
});
|
|
225
244
|
// Periodicky heartbeat kazdych 60 sekund — udržuje agent status + snižuje latenci task discovery
|
|
226
245
|
const heartbeatInterval = setInterval(() => {
|
|
227
|
-
api.post("/api/dev-queue/heartbeat", {}).catch(() => {
|
|
246
|
+
api.post("/api/dev-queue/heartbeat", {}).catch((err) => {
|
|
247
|
+
console.error("[tickr-mcp] heartbeat failed:", err instanceof Error ? err.message : String(err));
|
|
248
|
+
});
|
|
228
249
|
}, 60_000);
|
|
229
250
|
// Cleanup pri ukonceni procesu
|
|
230
251
|
const cleanup = async () => {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export function registerAddEpicDependency(server, api) {
|
|
3
|
+
server.tool("add_epic_dependency", "Add a dependency between two epics (predecessor blocks successor)", {
|
|
4
|
+
project: z.string().describe("Project slug"),
|
|
5
|
+
predecessor_epic_id: z.string().describe("UUID of the predecessor epic (must finish first)"),
|
|
6
|
+
successor_epic_id: z.string().describe("UUID of the successor epic (depends on predecessor)"),
|
|
7
|
+
type: z.string().optional().describe("Dependency type: finish-to-start (default), start-to-start, finish-to-finish, start-to-finish"),
|
|
8
|
+
}, async (params) => {
|
|
9
|
+
try {
|
|
10
|
+
const result = await api.post(`/api/projects/${params.project}/epics/${params.predecessor_epic_id}/dependencies`, { successorEpicId: params.successor_epic_id, dependencyType: params.type });
|
|
11
|
+
return {
|
|
12
|
+
content: [{ type: "text", text: `Dependency created (${params.type ?? "finish-to-start"}): ${params.predecessor_epic_id} → ${params.successor_epic_id}` }],
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
return {
|
|
17
|
+
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
18
|
+
isError: true,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=add-epic-dependency.js.map
|
package/dist/tools/list-epics.js
CHANGED
|
@@ -6,7 +6,7 @@ export function registerListEpics(server, api) {
|
|
|
6
6
|
try {
|
|
7
7
|
const epics = await api.get(`/api/projects/${params.project}/epics`);
|
|
8
8
|
const text = epics
|
|
9
|
-
.map((e) => `${e.name} (${e.id}) [${e.status}] (${e.
|
|
9
|
+
.map((e) => `${e.name} (${e.id}) [${e.status}] (${e.completedTicketCount}/${e.ticketCount})${e.targetDate ? ` target: ${e.targetDate}` : ""}`)
|
|
10
10
|
.join("\n");
|
|
11
11
|
return {
|
|
12
12
|
content: [{ type: "text", text: text || "No epics found." }],
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { ApiClient } from "../api-client.js";
|
|
3
|
+
export declare function registerRemoveEpicDependency(server: McpServer, api: ApiClient): void;
|
|
4
|
+
//# sourceMappingURL=remove-epic-dependency.d.ts.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export function registerRemoveEpicDependency(server, api) {
|
|
3
|
+
server.tool("remove_epic_dependency", "Remove a dependency between two epics", {
|
|
4
|
+
project: z.string().describe("Project slug"),
|
|
5
|
+
epic_id: z.string().describe("UUID of either epic in the dependency"),
|
|
6
|
+
dependency_id: z.string().describe("UUID of the dependency to remove"),
|
|
7
|
+
}, async (params) => {
|
|
8
|
+
try {
|
|
9
|
+
await api.delete(`/api/projects/${params.project}/epics/${params.epic_id}/dependencies/${params.dependency_id}`);
|
|
10
|
+
return {
|
|
11
|
+
content: [{ type: "text", text: `Dependency ${params.dependency_id} removed` }],
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
return {
|
|
16
|
+
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
17
|
+
isError: true,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=remove-epic-dependency.js.map
|
package/dist/types.d.ts
CHANGED
|
@@ -93,8 +93,8 @@ export interface Epic {
|
|
|
93
93
|
color: string;
|
|
94
94
|
status: string;
|
|
95
95
|
targetDate: string | null;
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
ticketCount: number;
|
|
97
|
+
completedTicketCount: number;
|
|
98
98
|
createdAt: string;
|
|
99
99
|
}
|
|
100
100
|
//# sourceMappingURL=types.d.ts.map
|