@ulinkly/mcp-server 0.1.12 → 0.1.13
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/auth/token-store.d.ts +6 -0
- package/dist/auth/token-store.js +31 -0
- package/dist/client/ulink-api.js +1 -0
- package/dist/index.js +3 -1
- package/dist/tools/__tests__/auth.test.d.ts +1 -0
- package/dist/tools/__tests__/auth.test.js +245 -0
- package/dist/tools/auth.d.ts +2 -0
- package/dist/tools/auth.js +219 -0
- package/package.json +1 -1
|
@@ -5,6 +5,12 @@ import type { OAuthTokens } from "./oauth.js";
|
|
|
5
5
|
* Returns undefined if file missing, auth missing, or expired.
|
|
6
6
|
*/
|
|
7
7
|
export declare function loadTokensFromDisk(): OAuthTokens | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Load just the refresh token from ~/.ulink/config.json, ignoring access token expiry.
|
|
10
|
+
* Used by check_auth_status to attempt token refresh when access token is expired.
|
|
11
|
+
* Returns undefined if no config, no auth, or no refresh token found.
|
|
12
|
+
*/
|
|
13
|
+
export declare function loadRefreshTokenFromDisk(): string | undefined;
|
|
8
14
|
/**
|
|
9
15
|
* Persist OAuthTokens to ~/.ulink/config.json using encrypted storage.
|
|
10
16
|
* Preserves projects, supabaseUrl, supabaseAnonKey and all other fields.
|
package/dist/auth/token-store.js
CHANGED
|
@@ -88,6 +88,37 @@ export function loadTokensFromDisk() {
|
|
|
88
88
|
return undefined;
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Load just the refresh token from ~/.ulink/config.json, ignoring access token expiry.
|
|
93
|
+
* Used by check_auth_status to attempt token refresh when access token is expired.
|
|
94
|
+
* Returns undefined if no config, no auth, or no refresh token found.
|
|
95
|
+
*/
|
|
96
|
+
export function loadRefreshTokenFromDisk() {
|
|
97
|
+
try {
|
|
98
|
+
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
|
99
|
+
const config = JSON.parse(raw);
|
|
100
|
+
// Try encrypted format first
|
|
101
|
+
if (typeof config.auth_encrypted === "string") {
|
|
102
|
+
try {
|
|
103
|
+
const decrypted = decrypt(config.auth_encrypted);
|
|
104
|
+
const auth = JSON.parse(decrypted);
|
|
105
|
+
return auth.refreshToken || undefined;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Decryption failed — fall through to legacy
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Legacy plaintext format
|
|
112
|
+
const auth = config.auth;
|
|
113
|
+
if (auth?.refreshToken) {
|
|
114
|
+
return auth.refreshToken;
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
91
122
|
/**
|
|
92
123
|
* Persist OAuthTokens to ~/.ulink/config.json using encrypted storage.
|
|
93
124
|
* Preserves projects, supabaseUrl, supabaseAnonKey and all other fields.
|
package/dist/client/ulink-api.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -9,12 +9,14 @@ import { registerLinkTools } from "./tools/links.js";
|
|
|
9
9
|
import { registerDomainTools } from "./tools/domains.js";
|
|
10
10
|
import { registerApiKeyTools } from "./tools/api-keys.js";
|
|
11
11
|
import { registerAccountTools } from "./tools/account.js";
|
|
12
|
+
import { registerAuthTools } from "./tools/auth.js";
|
|
12
13
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
14
|
const packageJson = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
14
15
|
const server = new McpServer({
|
|
15
16
|
name: "ulink",
|
|
16
17
|
version: String(packageJson.version),
|
|
17
18
|
});
|
|
19
|
+
registerAuthTools(server);
|
|
18
20
|
registerProjectTools(server);
|
|
19
21
|
registerLinkTools(server);
|
|
20
22
|
registerDomainTools(server);
|
|
@@ -23,7 +25,7 @@ registerAccountTools(server);
|
|
|
23
25
|
async function main() {
|
|
24
26
|
const transport = new StdioServerTransport();
|
|
25
27
|
await server.connect(transport);
|
|
26
|
-
console.error("ULink MCP Server running on stdio (
|
|
28
|
+
console.error("ULink MCP Server running on stdio (24 tools registered)");
|
|
27
29
|
}
|
|
28
30
|
main().catch((error) => {
|
|
29
31
|
console.error("Fatal error:", error);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { createMockServer } from "./_helpers.js";
|
|
3
|
+
vi.mock("../../auth/api-key.js", () => ({
|
|
4
|
+
getApiKey: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
vi.mock("../../auth/oauth.js", () => ({
|
|
7
|
+
browserOAuthFlow: vi.fn(),
|
|
8
|
+
refreshAccessToken: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
vi.mock("../../auth/token-store.js", () => ({
|
|
11
|
+
loadTokensFromDisk: vi.fn(),
|
|
12
|
+
loadRefreshTokenFromDisk: vi.fn(),
|
|
13
|
+
saveTokensToDisk: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
vi.mock("../../client/ulink-api.js", () => ({
|
|
16
|
+
apiRequest: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
import { getApiKey } from "../../auth/api-key.js";
|
|
19
|
+
import { browserOAuthFlow, refreshAccessToken } from "../../auth/oauth.js";
|
|
20
|
+
import { loadTokensFromDisk, loadRefreshTokenFromDisk, saveTokensToDisk, } from "../../auth/token-store.js";
|
|
21
|
+
import { apiRequest } from "../../client/ulink-api.js";
|
|
22
|
+
import { registerAuthTools } from "../auth.js";
|
|
23
|
+
const mockedGetApiKey = vi.mocked(getApiKey);
|
|
24
|
+
const mockedBrowserOAuthFlow = vi.mocked(browserOAuthFlow);
|
|
25
|
+
const mockedRefreshAccessToken = vi.mocked(refreshAccessToken);
|
|
26
|
+
const mockedLoadTokensFromDisk = vi.mocked(loadTokensFromDisk);
|
|
27
|
+
const mockedLoadRefreshTokenFromDisk = vi.mocked(loadRefreshTokenFromDisk);
|
|
28
|
+
const mockedSaveTokensToDisk = vi.mocked(saveTokensToDisk);
|
|
29
|
+
const mockedApiRequest = vi.mocked(apiRequest);
|
|
30
|
+
describe("Auth tools", () => {
|
|
31
|
+
let getHandler;
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.resetAllMocks();
|
|
34
|
+
const mock = createMockServer();
|
|
35
|
+
getHandler = mock.getHandler;
|
|
36
|
+
registerAuthTools(mock.server);
|
|
37
|
+
});
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// check_auth_status
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
describe("check_auth_status", () => {
|
|
42
|
+
it("returns api_key method when ULINK_API_KEY is set", async () => {
|
|
43
|
+
mockedGetApiKey.mockReturnValue("test-api-key-123");
|
|
44
|
+
const handler = getHandler("check_auth_status");
|
|
45
|
+
const result = await handler({});
|
|
46
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
47
|
+
expect(parsed.authenticated).toBe(true);
|
|
48
|
+
expect(parsed.method).toBe("api_key");
|
|
49
|
+
});
|
|
50
|
+
it("returns authenticated when valid tokens on disk", async () => {
|
|
51
|
+
mockedGetApiKey.mockReturnValue(undefined);
|
|
52
|
+
mockedLoadTokensFromDisk.mockReturnValue({
|
|
53
|
+
accessToken: "access-tok",
|
|
54
|
+
refreshToken: "refresh-tok",
|
|
55
|
+
expiresAt: Date.now() + 3600_000,
|
|
56
|
+
});
|
|
57
|
+
const handler = getHandler("check_auth_status");
|
|
58
|
+
const result = await handler({});
|
|
59
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
60
|
+
expect(parsed.authenticated).toBe(true);
|
|
61
|
+
expect(parsed.method).toBe("oauth");
|
|
62
|
+
expect(parsed.expiresAt).toBeDefined();
|
|
63
|
+
});
|
|
64
|
+
it("attempts refresh when tokens expired but refresh token available", async () => {
|
|
65
|
+
mockedGetApiKey.mockReturnValue(undefined);
|
|
66
|
+
mockedLoadTokensFromDisk.mockReturnValue(undefined);
|
|
67
|
+
mockedLoadRefreshTokenFromDisk.mockReturnValue("old-refresh-tok");
|
|
68
|
+
const newTokens = {
|
|
69
|
+
accessToken: "new-access",
|
|
70
|
+
refreshToken: "new-refresh",
|
|
71
|
+
expiresAt: Date.now() + 3600_000,
|
|
72
|
+
};
|
|
73
|
+
mockedRefreshAccessToken.mockResolvedValue(newTokens);
|
|
74
|
+
const handler = getHandler("check_auth_status");
|
|
75
|
+
const result = await handler({});
|
|
76
|
+
expect(mockedRefreshAccessToken).toHaveBeenCalledWith("old-refresh-tok");
|
|
77
|
+
expect(mockedSaveTokensToDisk).toHaveBeenCalledWith(newTokens);
|
|
78
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
79
|
+
expect(parsed.authenticated).toBe(true);
|
|
80
|
+
expect(parsed.method).toBe("oauth");
|
|
81
|
+
});
|
|
82
|
+
it("returns unauthenticated when no tokens and refresh fails", async () => {
|
|
83
|
+
mockedGetApiKey.mockReturnValue(undefined);
|
|
84
|
+
mockedLoadTokensFromDisk.mockReturnValue(undefined);
|
|
85
|
+
mockedLoadRefreshTokenFromDisk.mockReturnValue("stale-refresh");
|
|
86
|
+
mockedRefreshAccessToken.mockRejectedValue(new Error("Token refresh failed (401)"));
|
|
87
|
+
const handler = getHandler("check_auth_status");
|
|
88
|
+
const result = await handler({});
|
|
89
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
90
|
+
expect(parsed.authenticated).toBe(false);
|
|
91
|
+
expect(parsed.message).toContain("Not authenticated");
|
|
92
|
+
});
|
|
93
|
+
it("returns unauthenticated when no tokens and no refresh token", async () => {
|
|
94
|
+
mockedGetApiKey.mockReturnValue(undefined);
|
|
95
|
+
mockedLoadTokensFromDisk.mockReturnValue(undefined);
|
|
96
|
+
mockedLoadRefreshTokenFromDisk.mockReturnValue(undefined);
|
|
97
|
+
const handler = getHandler("check_auth_status");
|
|
98
|
+
const result = await handler({});
|
|
99
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
100
|
+
expect(parsed.authenticated).toBe(false);
|
|
101
|
+
expect(parsed.message).toContain("Not authenticated");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// authenticate
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
describe("authenticate", () => {
|
|
108
|
+
it("returns already authenticated when API key set", async () => {
|
|
109
|
+
mockedGetApiKey.mockReturnValue("test-api-key");
|
|
110
|
+
const handler = getHandler("authenticate");
|
|
111
|
+
const result = await handler({});
|
|
112
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
113
|
+
expect(parsed.authenticated).toBe(true);
|
|
114
|
+
expect(parsed.message).toContain("Already authenticated via API key");
|
|
115
|
+
});
|
|
116
|
+
it("returns already authenticated when valid tokens exist", async () => {
|
|
117
|
+
mockedGetApiKey.mockReturnValue(undefined);
|
|
118
|
+
mockedLoadTokensFromDisk.mockReturnValue({
|
|
119
|
+
accessToken: "access-tok",
|
|
120
|
+
refreshToken: "refresh-tok",
|
|
121
|
+
expiresAt: Date.now() + 3600_000,
|
|
122
|
+
});
|
|
123
|
+
const handler = getHandler("authenticate");
|
|
124
|
+
const result = await handler({});
|
|
125
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
126
|
+
expect(parsed.authenticated).toBe(true);
|
|
127
|
+
expect(parsed.message).toContain("Already authenticated");
|
|
128
|
+
});
|
|
129
|
+
it("triggers browserOAuthFlow and saves tokens when no auth exists", async () => {
|
|
130
|
+
mockedGetApiKey.mockReturnValue(undefined);
|
|
131
|
+
mockedLoadTokensFromDisk.mockReturnValue(undefined);
|
|
132
|
+
const tokens = {
|
|
133
|
+
accessToken: "new-access",
|
|
134
|
+
refreshToken: "new-refresh",
|
|
135
|
+
expiresAt: Date.now() + 3600_000,
|
|
136
|
+
};
|
|
137
|
+
mockedBrowserOAuthFlow.mockResolvedValue(tokens);
|
|
138
|
+
const handler = getHandler("authenticate");
|
|
139
|
+
const result = await handler({});
|
|
140
|
+
expect(mockedBrowserOAuthFlow).toHaveBeenCalled();
|
|
141
|
+
expect(mockedSaveTokensToDisk).toHaveBeenCalledWith(tokens);
|
|
142
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
143
|
+
expect(parsed.authenticated).toBe(true);
|
|
144
|
+
expect(parsed.message).toContain("Successfully authenticated");
|
|
145
|
+
});
|
|
146
|
+
it("returns error when OAuth times out", async () => {
|
|
147
|
+
mockedGetApiKey.mockReturnValue(undefined);
|
|
148
|
+
mockedLoadTokensFromDisk.mockReturnValue(undefined);
|
|
149
|
+
mockedBrowserOAuthFlow.mockRejectedValue(new Error("OAuth flow timed out after 5 minutes"));
|
|
150
|
+
const handler = getHandler("authenticate");
|
|
151
|
+
const result = await handler({});
|
|
152
|
+
expect(result.isError).toBe(true);
|
|
153
|
+
expect(result.content[0].text).toContain("timed out");
|
|
154
|
+
});
|
|
155
|
+
it("returns error when OAuth fails", async () => {
|
|
156
|
+
mockedGetApiKey.mockReturnValue(undefined);
|
|
157
|
+
mockedLoadTokensFromDisk.mockReturnValue(undefined);
|
|
158
|
+
mockedBrowserOAuthFlow.mockRejectedValue(new Error("OAuth error: access_denied"));
|
|
159
|
+
const handler = getHandler("authenticate");
|
|
160
|
+
const result = await handler({});
|
|
161
|
+
expect(result.isError).toBe(true);
|
|
162
|
+
expect(result.content[0].text).toContain("Authentication failed");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// get_onboarding_status
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
describe("get_onboarding_status", () => {
|
|
169
|
+
it("returns error when not authenticated", async () => {
|
|
170
|
+
mockedGetApiKey.mockReturnValue(undefined);
|
|
171
|
+
mockedLoadTokensFromDisk.mockReturnValue(undefined);
|
|
172
|
+
const handler = getHandler("get_onboarding_status");
|
|
173
|
+
const result = await handler({ projectId: "proj-1" });
|
|
174
|
+
expect(result.isError).toBe(true);
|
|
175
|
+
expect(result.content[0].text).toContain("Not authenticated");
|
|
176
|
+
});
|
|
177
|
+
it("calls correct API endpoint with projectId", async () => {
|
|
178
|
+
mockedGetApiKey.mockReturnValue("test-key");
|
|
179
|
+
mockedApiRequest.mockResolvedValue({
|
|
180
|
+
domain_setup_completed: false,
|
|
181
|
+
platform_selection_completed: false,
|
|
182
|
+
platform_config_completed: false,
|
|
183
|
+
platform_implementation_viewed: false,
|
|
184
|
+
cli_verified: false,
|
|
185
|
+
sdk_setup_viewed: false,
|
|
186
|
+
});
|
|
187
|
+
const handler = getHandler("get_onboarding_status");
|
|
188
|
+
await handler({ projectId: "proj-1" });
|
|
189
|
+
expect(mockedApiRequest).toHaveBeenCalledWith("GET", "/projects/proj-1/onboarding");
|
|
190
|
+
});
|
|
191
|
+
it("computes nextStep as domain_setup when nothing done", async () => {
|
|
192
|
+
mockedGetApiKey.mockReturnValue("test-key");
|
|
193
|
+
mockedApiRequest.mockResolvedValue({
|
|
194
|
+
domain_setup_completed: false,
|
|
195
|
+
platform_selection_completed: false,
|
|
196
|
+
platform_config_completed: false,
|
|
197
|
+
platform_implementation_viewed: false,
|
|
198
|
+
cli_verified: false,
|
|
199
|
+
sdk_setup_viewed: false,
|
|
200
|
+
});
|
|
201
|
+
const handler = getHandler("get_onboarding_status");
|
|
202
|
+
const result = await handler({ projectId: "proj-1" });
|
|
203
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
204
|
+
expect(parsed.nextStep).toBe("domain_setup");
|
|
205
|
+
expect(parsed.completedSteps).toBe(0);
|
|
206
|
+
expect(parsed.completionPercentage).toBe(0);
|
|
207
|
+
});
|
|
208
|
+
it("computes nextStep as platform_config when first 2 steps done", async () => {
|
|
209
|
+
mockedGetApiKey.mockReturnValue("test-key");
|
|
210
|
+
mockedApiRequest.mockResolvedValue({
|
|
211
|
+
domain_setup_completed: true,
|
|
212
|
+
platform_selection_completed: true,
|
|
213
|
+
platform_config_completed: false,
|
|
214
|
+
platform_implementation_viewed: false,
|
|
215
|
+
cli_verified: false,
|
|
216
|
+
sdk_setup_viewed: false,
|
|
217
|
+
});
|
|
218
|
+
const handler = getHandler("get_onboarding_status");
|
|
219
|
+
const result = await handler({ projectId: "proj-1" });
|
|
220
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
221
|
+
expect(parsed.nextStep).toBe("platform_config");
|
|
222
|
+
expect(parsed.completedSteps).toBe(2);
|
|
223
|
+
expect(parsed.completionPercentage).toBe(33);
|
|
224
|
+
});
|
|
225
|
+
it("returns complete with 100% when all steps done", async () => {
|
|
226
|
+
mockedGetApiKey.mockReturnValue("test-key");
|
|
227
|
+
mockedApiRequest.mockResolvedValue({
|
|
228
|
+
domain_setup_completed: true,
|
|
229
|
+
platform_selection_completed: true,
|
|
230
|
+
platform_config_completed: true,
|
|
231
|
+
platform_implementation_viewed: true,
|
|
232
|
+
cli_verified: true,
|
|
233
|
+
sdk_setup_viewed: true,
|
|
234
|
+
});
|
|
235
|
+
const handler = getHandler("get_onboarding_status");
|
|
236
|
+
const result = await handler({ projectId: "proj-1" });
|
|
237
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
238
|
+
expect(parsed.nextStep).toBe("complete");
|
|
239
|
+
expect(parsed.completedSteps).toBe(6);
|
|
240
|
+
expect(parsed.totalSteps).toBe(6);
|
|
241
|
+
expect(parsed.completionPercentage).toBe(100);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
//# sourceMappingURL=auth.test.js.map
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getApiKey } from "../auth/api-key.js";
|
|
3
|
+
import { browserOAuthFlow, refreshAccessToken, } from "../auth/oauth.js";
|
|
4
|
+
import { loadTokensFromDisk, loadRefreshTokenFromDisk, saveTokensToDisk, } from "../auth/token-store.js";
|
|
5
|
+
import { apiRequest } from "../client/ulink-api.js";
|
|
6
|
+
export function registerAuthTools(server) {
|
|
7
|
+
// -----------------------------------------------------------------------
|
|
8
|
+
// check_auth_status
|
|
9
|
+
// -----------------------------------------------------------------------
|
|
10
|
+
server.registerTool("check_auth_status", {
|
|
11
|
+
title: "Check Auth Status",
|
|
12
|
+
description: "Check whether valid ULink authentication credentials exist. Returns authentication state without triggering any login flow. Call this first to determine if the authenticate tool needs to be called.",
|
|
13
|
+
annotations: { readOnlyHint: true },
|
|
14
|
+
inputSchema: {},
|
|
15
|
+
}, async () => {
|
|
16
|
+
try {
|
|
17
|
+
// 1. Check API key
|
|
18
|
+
const apiKey = getApiKey();
|
|
19
|
+
if (apiKey) {
|
|
20
|
+
return {
|
|
21
|
+
content: [{
|
|
22
|
+
type: "text",
|
|
23
|
+
text: JSON.stringify({ authenticated: true, method: "api_key" }),
|
|
24
|
+
}],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
// 2. Check disk tokens
|
|
28
|
+
const tokens = loadTokensFromDisk();
|
|
29
|
+
if (tokens) {
|
|
30
|
+
return {
|
|
31
|
+
content: [{
|
|
32
|
+
type: "text",
|
|
33
|
+
text: JSON.stringify({
|
|
34
|
+
authenticated: true,
|
|
35
|
+
method: "oauth",
|
|
36
|
+
expiresAt: tokens.expiresAt,
|
|
37
|
+
}),
|
|
38
|
+
}],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// 3. Attempt refresh with expired token's refresh token
|
|
42
|
+
const refreshToken = loadRefreshTokenFromDisk();
|
|
43
|
+
if (refreshToken) {
|
|
44
|
+
try {
|
|
45
|
+
const newTokens = await refreshAccessToken(refreshToken);
|
|
46
|
+
saveTokensToDisk(newTokens);
|
|
47
|
+
return {
|
|
48
|
+
content: [{
|
|
49
|
+
type: "text",
|
|
50
|
+
text: JSON.stringify({
|
|
51
|
+
authenticated: true,
|
|
52
|
+
method: "oauth",
|
|
53
|
+
expiresAt: newTokens.expiresAt,
|
|
54
|
+
}),
|
|
55
|
+
}],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Refresh failed — fall through to unauthenticated
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// 4. Not authenticated
|
|
63
|
+
return {
|
|
64
|
+
content: [{
|
|
65
|
+
type: "text",
|
|
66
|
+
text: JSON.stringify({
|
|
67
|
+
authenticated: false,
|
|
68
|
+
message: "Not authenticated. Call the 'authenticate' tool to sign in or create a free ULink account.",
|
|
69
|
+
}),
|
|
70
|
+
}],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
return {
|
|
75
|
+
content: [{
|
|
76
|
+
type: "text",
|
|
77
|
+
text: `Error checking auth status: ${err instanceof Error ? err.message : String(err)}`,
|
|
78
|
+
}],
|
|
79
|
+
isError: true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
// -----------------------------------------------------------------------
|
|
84
|
+
// authenticate
|
|
85
|
+
// -----------------------------------------------------------------------
|
|
86
|
+
server.registerTool("authenticate", {
|
|
87
|
+
title: "Authenticate",
|
|
88
|
+
description: "Authenticate with ULink by opening a browser window for sign-in or sign-up. No existing account required — new users can create a free account during this flow. This is the first tool to call if check_auth_status reports no valid credentials. After success, all other ULink tools become usable.",
|
|
89
|
+
annotations: { readOnlyHint: false },
|
|
90
|
+
inputSchema: {},
|
|
91
|
+
}, async () => {
|
|
92
|
+
try {
|
|
93
|
+
// 1. Check API key
|
|
94
|
+
const apiKey = getApiKey();
|
|
95
|
+
if (apiKey) {
|
|
96
|
+
return {
|
|
97
|
+
content: [{
|
|
98
|
+
type: "text",
|
|
99
|
+
text: JSON.stringify({
|
|
100
|
+
authenticated: true,
|
|
101
|
+
message: "Already authenticated via API key.",
|
|
102
|
+
}),
|
|
103
|
+
}],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// 2. Check existing tokens
|
|
107
|
+
const existing = loadTokensFromDisk();
|
|
108
|
+
if (existing) {
|
|
109
|
+
return {
|
|
110
|
+
content: [{
|
|
111
|
+
type: "text",
|
|
112
|
+
text: JSON.stringify({
|
|
113
|
+
authenticated: true,
|
|
114
|
+
message: "Already authenticated with ULink. All tools are ready to use.",
|
|
115
|
+
}),
|
|
116
|
+
}],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// 3. Run browser OAuth flow
|
|
120
|
+
const tokens = await browserOAuthFlow();
|
|
121
|
+
saveTokensToDisk(tokens);
|
|
122
|
+
return {
|
|
123
|
+
content: [{
|
|
124
|
+
type: "text",
|
|
125
|
+
text: JSON.stringify({
|
|
126
|
+
authenticated: true,
|
|
127
|
+
message: "Successfully authenticated with ULink. You can now use all ULink tools to manage projects, links, and domains.",
|
|
128
|
+
}),
|
|
129
|
+
}],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
134
|
+
return {
|
|
135
|
+
content: [{
|
|
136
|
+
type: "text",
|
|
137
|
+
text: `Authentication failed: ${reason}. Please try again.`,
|
|
138
|
+
}],
|
|
139
|
+
isError: true,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
// -----------------------------------------------------------------------
|
|
144
|
+
// get_onboarding_status
|
|
145
|
+
// -----------------------------------------------------------------------
|
|
146
|
+
server.registerTool("get_onboarding_status", {
|
|
147
|
+
title: "Get Onboarding Status",
|
|
148
|
+
description: "Get the onboarding progress for a ULink project, including which setup steps are complete and what to do next. Requires authentication. Use this after creating a project to guide users through setup.",
|
|
149
|
+
annotations: { readOnlyHint: true },
|
|
150
|
+
inputSchema: {
|
|
151
|
+
projectId: z
|
|
152
|
+
.string()
|
|
153
|
+
.describe("The project ID to check onboarding status for."),
|
|
154
|
+
},
|
|
155
|
+
}, async ({ projectId }) => {
|
|
156
|
+
try {
|
|
157
|
+
// Pre-check: verify auth exists to avoid unexpected browser popup
|
|
158
|
+
const apiKey = getApiKey();
|
|
159
|
+
const tokens = loadTokensFromDisk();
|
|
160
|
+
if (!apiKey && !tokens) {
|
|
161
|
+
return {
|
|
162
|
+
content: [{
|
|
163
|
+
type: "text",
|
|
164
|
+
text: "Not authenticated. Call the 'authenticate' tool first, then retry.",
|
|
165
|
+
}],
|
|
166
|
+
isError: true,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const data = await apiRequest("GET", `/projects/${encodeURIComponent(projectId)}/onboarding`);
|
|
170
|
+
// Compute next step from boolean flags
|
|
171
|
+
const steps = [
|
|
172
|
+
{ key: "domain_setup_completed", step: "domain_setup", desc: "Set up a domain using the add_domain tool" },
|
|
173
|
+
{ key: "platform_selection_completed", step: "platform_selection", desc: "Select target platforms using configure_project" },
|
|
174
|
+
{ key: "platform_config_completed", step: "platform_config", desc: "Complete platform configuration (bundle ID, package name, etc.)" },
|
|
175
|
+
{ key: "platform_implementation_viewed", step: "platform_implementation", desc: "Review platform implementation guide for your selected platforms" },
|
|
176
|
+
{ key: "cli_verified", step: "cli_verification", desc: "Verify setup by running 'ulink verify' CLI command" },
|
|
177
|
+
{ key: "sdk_setup_viewed", step: "sdk_setup", desc: "Review SDK setup guide for creating and receiving links" },
|
|
178
|
+
];
|
|
179
|
+
let completedCount = 0;
|
|
180
|
+
let nextStep = "complete";
|
|
181
|
+
let nextStepDescription = "Onboarding complete! You can now create links with create_link.";
|
|
182
|
+
let foundNext = false;
|
|
183
|
+
for (const s of steps) {
|
|
184
|
+
if (data[s.key]) {
|
|
185
|
+
completedCount++;
|
|
186
|
+
}
|
|
187
|
+
else if (!foundNext) {
|
|
188
|
+
nextStep = s.step;
|
|
189
|
+
nextStepDescription = s.desc;
|
|
190
|
+
foundNext = true;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const completionPercentage = Math.round((completedCount / steps.length) * 100);
|
|
194
|
+
return {
|
|
195
|
+
content: [{
|
|
196
|
+
type: "text",
|
|
197
|
+
text: JSON.stringify({
|
|
198
|
+
...data,
|
|
199
|
+
nextStep,
|
|
200
|
+
nextStepDescription,
|
|
201
|
+
completedSteps: completedCount,
|
|
202
|
+
totalSteps: steps.length,
|
|
203
|
+
completionPercentage,
|
|
204
|
+
}, null, 2),
|
|
205
|
+
}],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
return {
|
|
210
|
+
content: [{
|
|
211
|
+
type: "text",
|
|
212
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
213
|
+
}],
|
|
214
|
+
isError: true,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
//# sourceMappingURL=auth.js.map
|