@kylewadegrove/cutline-mcp-cli 0.1.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/README.md ADDED
@@ -0,0 +1,173 @@
1
+ # Cutline MCP CLI
2
+
3
+ Command-line tool for authenticating with Cutline MCP servers.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ cd functions/cutline/mcp-cli
9
+ npm install
10
+ npm run build
11
+ npm link # Makes cutline-mcp available globally
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ### Login
17
+
18
+ Authenticate with Cutline and store credentials securely in your OS keychain:
19
+
20
+ ```bash
21
+ cutline-mcp login
22
+ ```
23
+
24
+ This will:
25
+ 1. Open your browser to Cutline's authentication page
26
+ 2. After you log in, receive a refresh token
27
+ 3. Store the token securely in your system keychain
28
+ 4. Display confirmation with your email
29
+
30
+ ### Check Status
31
+
32
+ View your current authentication status:
33
+
34
+ ```bash
35
+ cutline-mcp status
36
+ ```
37
+
38
+ Shows:
39
+ - Whether you're authenticated
40
+ - Your email and user ID
41
+ - Token expiration time
42
+ - Subscription status (coming soon)
43
+
44
+ ### Logout
45
+
46
+ Remove stored credentials:
47
+
48
+ ```bash
49
+ cutline-mcp logout
50
+ ```
51
+
52
+ ## How It Works
53
+
54
+ ### Security
55
+
56
+ - **Keychain Storage**: Tokens are stored in your OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service)
57
+ - **Refresh Tokens**: Long-lived refresh tokens are stored, not short-lived ID tokens
58
+ - **Automatic Refresh**: MCP servers automatically exchange refresh tokens for fresh ID tokens
59
+ - **Revocable**: Tokens can be revoked from your Cutline account settings
60
+
61
+ ### Authentication Flow
62
+
63
+ ```
64
+ 1. User runs: cutline-mcp login
65
+ 2. CLI starts local callback server on localhost:8765
66
+ 3. CLI opens browser to: https://cutline.app/mcp-auth?callback=http://localhost:8765
67
+ 4. User logs in to Cutline (or is already logged in)
68
+ 5. Cutline redirects to: http://localhost:8765?token=REFRESH_TOKEN&email=user@example.com
69
+ 6. CLI receives token and stores in keychain
70
+ 7. CLI displays success message
71
+ ```
72
+
73
+ ### MCP Server Integration
74
+
75
+ MCP servers automatically read tokens from the keychain:
76
+
77
+ ```typescript
78
+ // In utils.ts
79
+ async function requirePremium(authToken?: string) {
80
+ // Priority: explicit token > keychain > error
81
+ let token = authToken;
82
+
83
+ if (!token) {
84
+ const refreshToken = await getStoredRefreshToken();
85
+ if (refreshToken) {
86
+ token = await exchangeRefreshToken(refreshToken);
87
+ }
88
+ }
89
+
90
+ if (!token) {
91
+ throw new Error("Run 'cutline-mcp login' to authenticate");
92
+ }
93
+
94
+ // ... validate token and subscription
95
+ }
96
+ ```
97
+
98
+ ## Configuration
99
+
100
+ ### Environment Variables
101
+
102
+ - `CUTLINE_AUTH_URL`: Override auth endpoint (default: `https://cutline.app/mcp-auth`)
103
+ - `FIREBASE_API_KEY`: Firebase API key for token exchange
104
+
105
+ ### Callback Port
106
+
107
+ The CLI uses port `8765` for the OAuth callback. If this port is in use, you'll see an error. Close other applications and try again.
108
+
109
+ ## Troubleshooting
110
+
111
+ ### "Port 8765 is already in use"
112
+
113
+ Another application is using the callback port. Find and close it:
114
+
115
+ ```bash
116
+ lsof -i :8765
117
+ kill -9 <PID>
118
+ ```
119
+
120
+ ### "Authentication timeout"
121
+
122
+ The browser didn't complete the OAuth flow within 5 minutes. Try again:
123
+
124
+ ```bash
125
+ cutline-mcp login
126
+ ```
127
+
128
+ ### "Failed to refresh token"
129
+
130
+ Your stored token may be invalid or revoked. Log out and log in again:
131
+
132
+ ```bash
133
+ cutline-mcp logout
134
+ cutline-mcp login
135
+ ```
136
+
137
+ ### Keychain Access Denied
138
+
139
+ On macOS, you may need to grant Terminal/IDE access to Keychain:
140
+
141
+ 1. Open Keychain Access app
142
+ 2. Find "cutline-mcp" entry
143
+ 3. Right-click → Get Info → Access Control
144
+ 4. Add your Terminal/IDE to allowed applications
145
+
146
+ ## Development
147
+
148
+ ### Build
149
+
150
+ ```bash
151
+ npm run build
152
+ ```
153
+
154
+ ### Watch Mode
155
+
156
+ ```bash
157
+ npm run dev
158
+ ```
159
+
160
+ ### Test Locally
161
+
162
+ ```bash
163
+ npm link
164
+ cutline-mcp --help
165
+ ```
166
+
167
+ ## Next Steps
168
+
169
+ - [ ] Add web app `/mcp-auth` endpoint
170
+ - [ ] Implement device registration in Firestore
171
+ - [ ] Add subscription status to `status` command
172
+ - [ ] Publish to npm as `@cutline/mcp-cli`
173
+ - [ ] Create standalone binaries for macOS/Windows/Linux
@@ -0,0 +1,5 @@
1
+ export interface CallbackResult {
2
+ token: string;
3
+ email?: string;
4
+ }
5
+ export declare function startCallbackServer(): Promise<CallbackResult>;
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.startCallbackServer = startCallbackServer;
7
+ const express_1 = __importDefault(require("express"));
8
+ const CALLBACK_PORT = 8765;
9
+ const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
10
+ async function startCallbackServer() {
11
+ return new Promise((resolve, reject) => {
12
+ const app = (0, express_1.default)();
13
+ let server;
14
+ // Timeout handler
15
+ const timeout = setTimeout(() => {
16
+ server?.close();
17
+ reject(new Error('Authentication timeout - no callback received'));
18
+ }, TIMEOUT_MS);
19
+ // Callback endpoint
20
+ app.get('/', (req, res) => {
21
+ const token = req.query.token;
22
+ const email = req.query.email;
23
+ if (!token) {
24
+ res.status(400).send('Missing token parameter');
25
+ return;
26
+ }
27
+ // Send success page
28
+ res.send(`
29
+ <!DOCTYPE html>
30
+ <html>
31
+ <head>
32
+ <title>Cutline MCP - Authentication Successful</title>
33
+ <style>
34
+ body {
35
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
36
+ display: flex;
37
+ justify-content: center;
38
+ align-items: center;
39
+ height: 100vh;
40
+ margin: 0;
41
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
42
+ }
43
+ .container {
44
+ background: white;
45
+ padding: 3rem;
46
+ border-radius: 1rem;
47
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3);
48
+ text-align: center;
49
+ max-width: 400px;
50
+ }
51
+ h1 {
52
+ color: #667eea;
53
+ margin-bottom: 1rem;
54
+ }
55
+ p {
56
+ color: #666;
57
+ line-height: 1.6;
58
+ }
59
+ .checkmark {
60
+ font-size: 4rem;
61
+ color: #4CAF50;
62
+ }
63
+ </style>
64
+ </head>
65
+ <body>
66
+ <div class="container">
67
+ <div class="checkmark">āœ“</div>
68
+ <h1>Authentication Successful!</h1>
69
+ <p>You can now close this window and return to your terminal.</p>
70
+ ${email ? `<p>Logged in as: <strong>${email}</strong></p>` : ''}
71
+ </div>
72
+ </body>
73
+ </html>
74
+ `);
75
+ // Clean up and resolve
76
+ clearTimeout(timeout);
77
+ server.close();
78
+ resolve({ token, email });
79
+ });
80
+ // Start server
81
+ server = app.listen(CALLBACK_PORT, () => {
82
+ console.log(`Callback server listening on http://localhost:${CALLBACK_PORT}`);
83
+ });
84
+ server.on('error', (err) => {
85
+ clearTimeout(timeout);
86
+ if (err.code === 'EADDRINUSE') {
87
+ reject(new Error(`Port ${CALLBACK_PORT} is already in use. Please close other applications and try again.`));
88
+ }
89
+ else {
90
+ reject(err);
91
+ }
92
+ });
93
+ });
94
+ }
@@ -0,0 +1,3 @@
1
+ export declare function storeRefreshToken(token: string): Promise<void>;
2
+ export declare function getRefreshToken(): Promise<string | null>;
3
+ export declare function deleteRefreshToken(): Promise<boolean>;
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.storeRefreshToken = storeRefreshToken;
7
+ exports.getRefreshToken = getRefreshToken;
8
+ exports.deleteRefreshToken = deleteRefreshToken;
9
+ const keytar_1 = __importDefault(require("keytar"));
10
+ const SERVICE_NAME = 'cutline-mcp';
11
+ const ACCOUNT_NAME = 'refresh-token';
12
+ async function storeRefreshToken(token) {
13
+ await keytar_1.default.setPassword(SERVICE_NAME, ACCOUNT_NAME, token);
14
+ }
15
+ async function getRefreshToken() {
16
+ return await keytar_1.default.getPassword(SERVICE_NAME, ACCOUNT_NAME);
17
+ }
18
+ async function deleteRefreshToken() {
19
+ return await keytar_1.default.deletePassword(SERVICE_NAME, ACCOUNT_NAME);
20
+ }
@@ -0,0 +1,3 @@
1
+ export declare function loginCommand(options: {
2
+ staging?: boolean;
3
+ }): Promise<void>;
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.loginCommand = loginCommand;
7
+ const open_1 = __importDefault(require("open"));
8
+ const chalk_1 = __importDefault(require("chalk"));
9
+ const ora_1 = __importDefault(require("ora"));
10
+ const callback_js_1 = require("../auth/callback.js");
11
+ const keychain_js_1 = require("../auth/keychain.js");
12
+ const config_js_1 = require("../utils/config.js");
13
+ async function exchangeCustomToken(customToken, apiKey) {
14
+ const response = await fetch(`https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${apiKey}`, {
15
+ method: 'POST',
16
+ headers: { 'Content-Type': 'application/json' },
17
+ body: JSON.stringify({
18
+ token: customToken,
19
+ returnSecureToken: true,
20
+ }),
21
+ });
22
+ if (!response.ok) {
23
+ const error = await response.text();
24
+ throw new Error(`Failed to exchange custom token: ${error}`);
25
+ }
26
+ const data = await response.json();
27
+ return {
28
+ refreshToken: data.refreshToken,
29
+ email: data.email,
30
+ };
31
+ }
32
+ async function loginCommand(options) {
33
+ const config = (0, config_js_1.getConfig)(options);
34
+ console.log(chalk_1.default.bold('\nšŸ” Cutline MCP Authentication\n'));
35
+ if (options.staging) {
36
+ console.log(chalk_1.default.yellow(' āš ļø Using STAGING environment\n'));
37
+ }
38
+ if (!config.FIREBASE_API_KEY) {
39
+ const varName = options.staging ? 'FIREBASE_API_KEY_STAGING' : 'FIREBASE_API_KEY';
40
+ console.error(chalk_1.default.red(`Error: ${varName} environment variable is required.`));
41
+ console.error(chalk_1.default.gray('Please set it before running this command:'));
42
+ console.error(chalk_1.default.cyan(` export ${varName}=AIzaSy...`));
43
+ process.exit(1);
44
+ }
45
+ const spinner = (0, ora_1.default)('Starting authentication flow...').start();
46
+ try {
47
+ // Start callback server
48
+ spinner.text = 'Waiting for authentication...';
49
+ const serverPromise = (0, callback_js_1.startCallbackServer)();
50
+ // Open browser
51
+ const authUrl = `${config.AUTH_URL}?callback=${encodeURIComponent(config.CALLBACK_URL)}`;
52
+ await (0, open_1.default)(authUrl);
53
+ spinner.text = 'Browser opened - please complete authentication';
54
+ // Wait for callback with custom token
55
+ const result = await serverPromise;
56
+ // Exchange custom token for refresh token
57
+ spinner.text = 'Exchanging token...';
58
+ const { refreshToken, email } = await exchangeCustomToken(result.token, config.FIREBASE_API_KEY);
59
+ // Store refresh token
60
+ await (0, keychain_js_1.storeRefreshToken)(refreshToken);
61
+ spinner.succeed(chalk_1.default.green('Successfully authenticated!'));
62
+ if (email || result.email) {
63
+ console.log(chalk_1.default.gray(` Logged in as: ${email || result.email}`));
64
+ }
65
+ console.log(chalk_1.default.gray(' MCP servers can now access your account\n'));
66
+ console.log(chalk_1.default.dim(' Run'), chalk_1.default.cyan('cutline-mcp status'), chalk_1.default.dim('to verify\n'));
67
+ }
68
+ catch (error) {
69
+ spinner.fail(chalk_1.default.red('Authentication failed'));
70
+ if (error instanceof Error) {
71
+ console.error(chalk_1.default.red(` ${error.message}\n`));
72
+ }
73
+ process.exit(1);
74
+ }
75
+ }
@@ -0,0 +1 @@
1
+ export declare function logoutCommand(): Promise<void>;
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.logoutCommand = logoutCommand;
7
+ const chalk_1 = __importDefault(require("chalk"));
8
+ const ora_1 = __importDefault(require("ora"));
9
+ const keychain_js_1 = require("../auth/keychain.js");
10
+ async function logoutCommand() {
11
+ console.log(chalk_1.default.bold('\nšŸ‘‹ Logging out of Cutline MCP\n'));
12
+ const spinner = (0, ora_1.default)('Removing stored credentials...').start();
13
+ try {
14
+ const deleted = await (0, keychain_js_1.deleteRefreshToken)();
15
+ if (deleted) {
16
+ spinner.succeed(chalk_1.default.green('Successfully logged out'));
17
+ console.log(chalk_1.default.gray(' Credentials removed from keychain\n'));
18
+ }
19
+ else {
20
+ spinner.info(chalk_1.default.yellow('No credentials found'));
21
+ console.log(chalk_1.default.gray(' You were not logged in\n'));
22
+ }
23
+ }
24
+ catch (error) {
25
+ spinner.fail(chalk_1.default.red('Logout failed'));
26
+ if (error instanceof Error) {
27
+ console.error(chalk_1.default.red(` ${error.message}\n`));
28
+ }
29
+ process.exit(1);
30
+ }
31
+ }
@@ -0,0 +1,3 @@
1
+ export declare function statusCommand(options: {
2
+ staging?: boolean;
3
+ }): Promise<void>;
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.statusCommand = statusCommand;
7
+ const chalk_1 = __importDefault(require("chalk"));
8
+ const ora_1 = __importDefault(require("ora"));
9
+ const firebase_admin_1 = __importDefault(require("firebase-admin"));
10
+ const keychain_js_1 = require("../auth/keychain.js");
11
+ const config_js_1 = require("../utils/config.js");
12
+ async function exchangeRefreshToken(refreshToken, apiKey) {
13
+ const response = await fetch(`https://securetoken.googleapis.com/v1/token?key=${apiKey}`, {
14
+ method: 'POST',
15
+ headers: { 'Content-Type': 'application/json' },
16
+ body: JSON.stringify({
17
+ grant_type: 'refresh_token',
18
+ refresh_token: refreshToken,
19
+ }),
20
+ });
21
+ if (!response.ok) {
22
+ const errorText = await response.text();
23
+ let errorMessage = 'Failed to refresh token';
24
+ try {
25
+ const errorData = JSON.parse(errorText);
26
+ errorMessage = errorData.error?.message || errorText;
27
+ }
28
+ catch {
29
+ errorMessage = errorText;
30
+ }
31
+ throw new Error(errorMessage);
32
+ }
33
+ const data = await response.json();
34
+ return data.id_token;
35
+ }
36
+ async function statusCommand(options) {
37
+ console.log(chalk_1.default.bold('\nšŸ“Š Cutline MCP Status\n'));
38
+ const spinner = (0, ora_1.default)('Checking authentication...').start();
39
+ try {
40
+ // Check for stored refresh token
41
+ const refreshToken = await (0, keychain_js_1.getRefreshToken)();
42
+ if (!refreshToken) {
43
+ spinner.info(chalk_1.default.yellow('Not authenticated'));
44
+ console.log(chalk_1.default.gray(' Run'), chalk_1.default.cyan('cutline-mcp login'), chalk_1.default.gray('to authenticate\n'));
45
+ return;
46
+ }
47
+ // Get config for API key
48
+ const config = (0, config_js_1.getConfig)(options);
49
+ if (!config.FIREBASE_API_KEY) {
50
+ const varName = options.staging ? 'FIREBASE_API_KEY_STAGING' : 'FIREBASE_API_KEY';
51
+ spinner.fail(chalk_1.default.red('Configuration error'));
52
+ console.error(chalk_1.default.red(` ${varName} environment variable is required.`));
53
+ process.exit(1);
54
+ }
55
+ // Exchange refresh token for ID token
56
+ spinner.text = 'Verifying credentials...';
57
+ const idToken = await exchangeRefreshToken(refreshToken, config.FIREBASE_API_KEY);
58
+ // Initialize Firebase Admin with correct project ID
59
+ if (firebase_admin_1.default.apps.length === 0) {
60
+ const projectId = options.staging ? 'demo-makerkit' : 'makerkit-todo-app';
61
+ firebase_admin_1.default.initializeApp({ projectId });
62
+ }
63
+ // Verify the ID token
64
+ const decoded = await firebase_admin_1.default.auth().verifyIdToken(idToken);
65
+ spinner.succeed(chalk_1.default.green('Authenticated'));
66
+ console.log(chalk_1.default.gray(' User:'), chalk_1.default.white(decoded.email || decoded.uid));
67
+ console.log(chalk_1.default.gray(' UID:'), chalk_1.default.dim(decoded.uid));
68
+ // Calculate token expiry
69
+ const expiresIn = Math.floor((decoded.exp * 1000 - Date.now()) / 1000 / 60);
70
+ console.log(chalk_1.default.gray(' Token expires in:'), chalk_1.default.white(`${expiresIn} minutes`));
71
+ // Show custom claims if present
72
+ if (decoded.mcp) {
73
+ console.log(chalk_1.default.gray(' MCP enabled:'), chalk_1.default.green('āœ“'));
74
+ }
75
+ if (decoded.deviceId) {
76
+ console.log(chalk_1.default.gray(' Device ID:'), chalk_1.default.dim(decoded.deviceId));
77
+ }
78
+ // TODO: Check subscription status from Firestore
79
+ console.log(chalk_1.default.gray(' Subscription:'), chalk_1.default.dim('(checking...)'));
80
+ console.log();
81
+ }
82
+ catch (error) {
83
+ spinner.fail(chalk_1.default.red('Status check failed'));
84
+ if (error instanceof Error) {
85
+ console.error(chalk_1.default.red(` ${error.message}`));
86
+ console.log(chalk_1.default.gray(' Try running'), chalk_1.default.cyan('cutline-mcp login'), chalk_1.default.gray('again\n'));
87
+ }
88
+ process.exit(1);
89
+ }
90
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const commander_1 = require("commander");
5
+ const login_js_1 = require("./commands/login.js");
6
+ const logout_js_1 = require("./commands/logout.js");
7
+ const status_js_1 = require("./commands/status.js");
8
+ const program = new commander_1.Command();
9
+ program
10
+ .name('cutline-mcp')
11
+ .description('CLI tool for authenticating with Cutline MCP servers')
12
+ .version('0.1.0');
13
+ program
14
+ .command('login')
15
+ .description('Authenticate with Cutline and store credentials')
16
+ .option('--staging', 'Use staging environment')
17
+ .action(login_js_1.loginCommand);
18
+ program
19
+ .command('logout')
20
+ .description('Remove stored credentials')
21
+ .action(logout_js_1.logoutCommand);
22
+ program
23
+ .command('status')
24
+ .description('Show current authentication status')
25
+ .option('--staging', 'Use staging environment')
26
+ .action(status_js_1.statusCommand);
27
+ program.parse();
@@ -0,0 +1,8 @@
1
+ export interface Config {
2
+ AUTH_URL: string;
3
+ CALLBACK_URL: string;
4
+ FIREBASE_API_KEY: string;
5
+ }
6
+ export declare function getConfig(options?: {
7
+ staging?: boolean;
8
+ }): Config;
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getConfig = getConfig;
4
+ function getConfig(options = {}) {
5
+ if (options.staging) {
6
+ return {
7
+ AUTH_URL: process.env.CUTLINE_AUTH_URL || 'https://cutline-staging.web.app/mcp-auth',
8
+ CALLBACK_URL: 'http://localhost:8765',
9
+ FIREBASE_API_KEY: process.env.FIREBASE_API_KEY_STAGING || process.env.FIREBASE_API_KEY || 'AIzaSyAAqU_euGAMtJoXp0sECblAIndifCp0pmE',
10
+ };
11
+ }
12
+ return {
13
+ AUTH_URL: process.env.CUTLINE_AUTH_URL || 'https://thecutline.ai/mcp-auth',
14
+ CALLBACK_URL: 'http://localhost:8765',
15
+ FIREBASE_API_KEY: process.env.FIREBASE_API_KEY || 'AIzaSyDW7846oQfvFU3Vnc1DELj4XdlvvYFjaIU',
16
+ };
17
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@kylewadegrove/cutline-mcp-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI tool for authenticating with Cutline MCP servers",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "cutline-mcp": "./dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "tsc --watch",
12
+ "start": "node dist/index.js"
13
+ },
14
+ "keywords": [
15
+ "cutline",
16
+ "mcp",
17
+ "cli",
18
+ "authentication"
19
+ ],
20
+ "author": "Cutline",
21
+ "license": "MIT",
22
+ "dependencies": {
23
+ "commander": "^11.1.0",
24
+ "keytar": "^7.9.0",
25
+ "open": "^9.1.0",
26
+ "express": "^4.18.2",
27
+ "firebase-admin": "^12.0.0",
28
+ "chalk": "^4.1.2",
29
+ "ora": "^5.4.1"
30
+ },
31
+ "devDependencies": {
32
+ "@types/express": "^4.17.21",
33
+ "@types/node": "^20.0.0",
34
+ "typescript": "^5.3.0"
35
+ },
36
+ "engines": {
37
+ "node": ">=18.0.0"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }
@@ -0,0 +1,102 @@
1
+ import express from 'express';
2
+ import type { Server } from 'http';
3
+
4
+ const CALLBACK_PORT = 8765;
5
+ const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
6
+
7
+ export interface CallbackResult {
8
+ token: string;
9
+ email?: string;
10
+ }
11
+
12
+ export async function startCallbackServer(): Promise<CallbackResult> {
13
+ return new Promise((resolve, reject) => {
14
+ const app = express();
15
+ let server: Server;
16
+
17
+ // Timeout handler
18
+ const timeout = setTimeout(() => {
19
+ server?.close();
20
+ reject(new Error('Authentication timeout - no callback received'));
21
+ }, TIMEOUT_MS);
22
+
23
+ // Callback endpoint
24
+ app.get('/', (req, res) => {
25
+ const token = req.query.token as string;
26
+ const email = req.query.email as string;
27
+
28
+ if (!token) {
29
+ res.status(400).send('Missing token parameter');
30
+ return;
31
+ }
32
+
33
+ // Send success page
34
+ res.send(`
35
+ <!DOCTYPE html>
36
+ <html>
37
+ <head>
38
+ <title>Cutline MCP - Authentication Successful</title>
39
+ <style>
40
+ body {
41
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
42
+ display: flex;
43
+ justify-content: center;
44
+ align-items: center;
45
+ height: 100vh;
46
+ margin: 0;
47
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
48
+ }
49
+ .container {
50
+ background: white;
51
+ padding: 3rem;
52
+ border-radius: 1rem;
53
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3);
54
+ text-align: center;
55
+ max-width: 400px;
56
+ }
57
+ h1 {
58
+ color: #667eea;
59
+ margin-bottom: 1rem;
60
+ }
61
+ p {
62
+ color: #666;
63
+ line-height: 1.6;
64
+ }
65
+ .checkmark {
66
+ font-size: 4rem;
67
+ color: #4CAF50;
68
+ }
69
+ </style>
70
+ </head>
71
+ <body>
72
+ <div class="container">
73
+ <div class="checkmark">āœ“</div>
74
+ <h1>Authentication Successful!</h1>
75
+ <p>You can now close this window and return to your terminal.</p>
76
+ ${email ? `<p>Logged in as: <strong>${email}</strong></p>` : ''}
77
+ </div>
78
+ </body>
79
+ </html>
80
+ `);
81
+
82
+ // Clean up and resolve
83
+ clearTimeout(timeout);
84
+ server.close();
85
+ resolve({ token, email });
86
+ });
87
+
88
+ // Start server
89
+ server = app.listen(CALLBACK_PORT, () => {
90
+ console.log(`Callback server listening on http://localhost:${CALLBACK_PORT}`);
91
+ });
92
+
93
+ server.on('error', (err: any) => {
94
+ clearTimeout(timeout);
95
+ if (err.code === 'EADDRINUSE') {
96
+ reject(new Error(`Port ${CALLBACK_PORT} is already in use. Please close other applications and try again.`));
97
+ } else {
98
+ reject(err);
99
+ }
100
+ });
101
+ });
102
+ }
@@ -0,0 +1,16 @@
1
+ import keytar from 'keytar';
2
+
3
+ const SERVICE_NAME = 'cutline-mcp';
4
+ const ACCOUNT_NAME = 'refresh-token';
5
+
6
+ export async function storeRefreshToken(token: string): Promise<void> {
7
+ await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, token);
8
+ }
9
+
10
+ export async function getRefreshToken(): Promise<string | null> {
11
+ return await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
12
+ }
13
+
14
+ export async function deleteRefreshToken(): Promise<boolean> {
15
+ return await keytar.deletePassword(SERVICE_NAME, ACCOUNT_NAME);
16
+ }
@@ -0,0 +1,89 @@
1
+ import open from 'open';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { startCallbackServer } from '../auth/callback.js';
5
+ import { storeRefreshToken } from '../auth/keychain.js';
6
+ import { getConfig } from '../utils/config.js';
7
+
8
+ async function exchangeCustomToken(customToken: string, apiKey: string): Promise<{ refreshToken: string; email?: string }> {
9
+ const response = await fetch(
10
+ `https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${apiKey}`,
11
+ {
12
+ method: 'POST',
13
+ headers: { 'Content-Type': 'application/json' },
14
+ body: JSON.stringify({
15
+ token: customToken,
16
+ returnSecureToken: true,
17
+ }),
18
+ }
19
+ );
20
+
21
+ if (!response.ok) {
22
+ const error = await response.text();
23
+ throw new Error(`Failed to exchange custom token: ${error}`);
24
+ }
25
+
26
+ const data = await response.json();
27
+ return {
28
+ refreshToken: data.refreshToken,
29
+ email: data.email,
30
+ };
31
+ }
32
+
33
+ export async function loginCommand(options: { staging?: boolean }) {
34
+ const config = getConfig(options);
35
+ console.log(chalk.bold('\nšŸ” Cutline MCP Authentication\n'));
36
+
37
+ if (options.staging) {
38
+ console.log(chalk.yellow(' āš ļø Using STAGING environment\n'));
39
+ }
40
+
41
+ if (!config.FIREBASE_API_KEY) {
42
+ const varName = options.staging ? 'FIREBASE_API_KEY_STAGING' : 'FIREBASE_API_KEY';
43
+ console.error(chalk.red(`Error: ${varName} environment variable is required.`));
44
+ console.error(chalk.gray('Please set it before running this command:'));
45
+ console.error(chalk.cyan(` export ${varName}=AIzaSy...`));
46
+ process.exit(1);
47
+ }
48
+
49
+ const spinner = ora('Starting authentication flow...').start();
50
+
51
+ try {
52
+ // Start callback server
53
+ spinner.text = 'Waiting for authentication...';
54
+ const serverPromise = startCallbackServer();
55
+
56
+ // Open browser
57
+ const authUrl = `${config.AUTH_URL}?callback=${encodeURIComponent(config.CALLBACK_URL)}`;
58
+ await open(authUrl);
59
+ spinner.text = 'Browser opened - please complete authentication';
60
+
61
+ // Wait for callback with custom token
62
+ const result = await serverPromise;
63
+
64
+ // Exchange custom token for refresh token
65
+ spinner.text = 'Exchanging token...';
66
+ const { refreshToken, email } = await exchangeCustomToken(result.token, config.FIREBASE_API_KEY);
67
+
68
+ // Store refresh token
69
+ await storeRefreshToken(refreshToken);
70
+
71
+ spinner.succeed(chalk.green('Successfully authenticated!'));
72
+
73
+ if (email || result.email) {
74
+ console.log(chalk.gray(` Logged in as: ${email || result.email}`));
75
+ }
76
+
77
+ console.log(chalk.gray(' MCP servers can now access your account\n'));
78
+ console.log(chalk.dim(' Run'), chalk.cyan('cutline-mcp status'), chalk.dim('to verify\n'));
79
+
80
+ } catch (error) {
81
+ spinner.fail(chalk.red('Authentication failed'));
82
+
83
+ if (error instanceof Error) {
84
+ console.error(chalk.red(` ${error.message}\n`));
85
+ }
86
+
87
+ process.exit(1);
88
+ }
89
+ }
@@ -0,0 +1,30 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { deleteRefreshToken } from '../auth/keychain.js';
4
+
5
+ export async function logoutCommand() {
6
+ console.log(chalk.bold('\nšŸ‘‹ Logging out of Cutline MCP\n'));
7
+
8
+ const spinner = ora('Removing stored credentials...').start();
9
+
10
+ try {
11
+ const deleted = await deleteRefreshToken();
12
+
13
+ if (deleted) {
14
+ spinner.succeed(chalk.green('Successfully logged out'));
15
+ console.log(chalk.gray(' Credentials removed from keychain\n'));
16
+ } else {
17
+ spinner.info(chalk.yellow('No credentials found'));
18
+ console.log(chalk.gray(' You were not logged in\n'));
19
+ }
20
+
21
+ } catch (error) {
22
+ spinner.fail(chalk.red('Logout failed'));
23
+
24
+ if (error instanceof Error) {
25
+ console.error(chalk.red(` ${error.message}\n`));
26
+ }
27
+
28
+ process.exit(1);
29
+ }
30
+ }
@@ -0,0 +1,106 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import admin from 'firebase-admin';
4
+ import { getRefreshToken } from '../auth/keychain.js';
5
+ import { getConfig } from '../utils/config.js';
6
+
7
+ async function exchangeRefreshToken(refreshToken: string, apiKey: string): Promise<string> {
8
+ const response = await fetch(
9
+ `https://securetoken.googleapis.com/v1/token?key=${apiKey}`,
10
+ {
11
+ method: 'POST',
12
+ headers: { 'Content-Type': 'application/json' },
13
+ body: JSON.stringify({
14
+ grant_type: 'refresh_token',
15
+ refresh_token: refreshToken,
16
+ }),
17
+ }
18
+ );
19
+
20
+ if (!response.ok) {
21
+ const errorText = await response.text();
22
+ let errorMessage = 'Failed to refresh token';
23
+ try {
24
+ const errorData = JSON.parse(errorText);
25
+ errorMessage = errorData.error?.message || errorText;
26
+ } catch {
27
+ errorMessage = errorText;
28
+ }
29
+ throw new Error(errorMessage);
30
+ }
31
+
32
+ const data = await response.json();
33
+ return data.id_token;
34
+ }
35
+
36
+ export async function statusCommand(options: { staging?: boolean }) {
37
+ console.log(chalk.bold('\nšŸ“Š Cutline MCP Status\n'));
38
+
39
+ const spinner = ora('Checking authentication...').start();
40
+
41
+ try {
42
+ // Check for stored refresh token
43
+ const refreshToken = await getRefreshToken();
44
+
45
+ if (!refreshToken) {
46
+ spinner.info(chalk.yellow('Not authenticated'));
47
+ console.log(chalk.gray(' Run'), chalk.cyan('cutline-mcp login'), chalk.gray('to authenticate\n'));
48
+ return;
49
+ }
50
+
51
+ // Get config for API key
52
+ const config = getConfig(options);
53
+
54
+ if (!config.FIREBASE_API_KEY) {
55
+ const varName = options.staging ? 'FIREBASE_API_KEY_STAGING' : 'FIREBASE_API_KEY';
56
+ spinner.fail(chalk.red('Configuration error'));
57
+ console.error(chalk.red(` ${varName} environment variable is required.`));
58
+ process.exit(1);
59
+ }
60
+
61
+ // Exchange refresh token for ID token
62
+ spinner.text = 'Verifying credentials...';
63
+ const idToken = await exchangeRefreshToken(refreshToken, config.FIREBASE_API_KEY);
64
+
65
+ // Initialize Firebase Admin with correct project ID
66
+ if (admin.apps.length === 0) {
67
+ const projectId = options.staging ? 'demo-makerkit' : 'makerkit-todo-app';
68
+ admin.initializeApp({ projectId });
69
+ }
70
+
71
+ // Verify the ID token
72
+ const decoded = await admin.auth().verifyIdToken(idToken);
73
+
74
+ spinner.succeed(chalk.green('Authenticated'));
75
+
76
+ console.log(chalk.gray(' User:'), chalk.white(decoded.email || decoded.uid));
77
+ console.log(chalk.gray(' UID:'), chalk.dim(decoded.uid));
78
+
79
+ // Calculate token expiry
80
+ const expiresIn = Math.floor((decoded.exp * 1000 - Date.now()) / 1000 / 60);
81
+ console.log(chalk.gray(' Token expires in:'), chalk.white(`${expiresIn} minutes`));
82
+
83
+ // Show custom claims if present
84
+ if (decoded.mcp) {
85
+ console.log(chalk.gray(' MCP enabled:'), chalk.green('āœ“'));
86
+ }
87
+ if (decoded.deviceId) {
88
+ console.log(chalk.gray(' Device ID:'), chalk.dim(decoded.deviceId));
89
+ }
90
+
91
+ // TODO: Check subscription status from Firestore
92
+ console.log(chalk.gray(' Subscription:'), chalk.dim('(checking...)'));
93
+
94
+ console.log();
95
+
96
+ } catch (error) {
97
+ spinner.fail(chalk.red('Status check failed'));
98
+
99
+ if (error instanceof Error) {
100
+ console.error(chalk.red(` ${error.message}`));
101
+ console.log(chalk.gray(' Try running'), chalk.cyan('cutline-mcp login'), chalk.gray('again\n'));
102
+ }
103
+
104
+ process.exit(1);
105
+ }
106
+ }
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { loginCommand } from './commands/login.js';
4
+ import { logoutCommand } from './commands/logout.js';
5
+ import { statusCommand } from './commands/status.js';
6
+
7
+ const program = new Command();
8
+
9
+ program
10
+ .name('cutline-mcp')
11
+ .description('CLI tool for authenticating with Cutline MCP servers')
12
+ .version('0.1.0');
13
+
14
+ program
15
+ .command('login')
16
+ .description('Authenticate with Cutline and store credentials')
17
+ .option('--staging', 'Use staging environment')
18
+ .action(loginCommand);
19
+
20
+ program
21
+ .command('logout')
22
+ .description('Remove stored credentials')
23
+ .action(logoutCommand);
24
+
25
+ program
26
+ .command('status')
27
+ .description('Show current authentication status')
28
+ .option('--staging', 'Use staging environment')
29
+ .action(statusCommand);
30
+
31
+ program.parse();
@@ -0,0 +1,21 @@
1
+ export interface Config {
2
+ AUTH_URL: string;
3
+ CALLBACK_URL: string;
4
+ FIREBASE_API_KEY: string;
5
+ }
6
+
7
+ export function getConfig(options: { staging?: boolean } = {}): Config {
8
+ if (options.staging) {
9
+ return {
10
+ AUTH_URL: process.env.CUTLINE_AUTH_URL || 'https://cutline-staging.web.app/mcp-auth',
11
+ CALLBACK_URL: 'http://localhost:8765',
12
+ FIREBASE_API_KEY: process.env.FIREBASE_API_KEY_STAGING || process.env.FIREBASE_API_KEY || 'AIzaSyAAqU_euGAMtJoXp0sECblAIndifCp0pmE',
13
+ };
14
+ }
15
+
16
+ return {
17
+ AUTH_URL: process.env.CUTLINE_AUTH_URL || 'https://thecutline.ai/mcp-auth',
18
+ CALLBACK_URL: 'http://localhost:8765',
19
+ FIREBASE_API_KEY: process.env.FIREBASE_API_KEY || 'AIzaSyDW7846oQfvFU3Vnc1DELj4XdlvvYFjaIU',
20
+ };
21
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true
14
+ },
15
+ "include": [
16
+ "src/**/*"
17
+ ],
18
+ "exclude": [
19
+ "node_modules",
20
+ "dist"
21
+ ]
22
+ }