add usage tracker

This commit is contained in:
2026-03-10 10:08:59 +01:00
parent 61af112f74
commit 7033d45a80
6 changed files with 4046 additions and 0 deletions

50
.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# Dependencies
node_modules/
# Builds
dist/
build/
coverage/
# Vite / tooling caches
.vite/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
bun-debug.log*
# Env files (keep .env.example committed)
.env
.env.*
!.env.example
# TypeScript incremental build info
*.tsbuildinfo
# OS / editor cruft
.DS_Store
Thumbs.db
# JetBrains (either ignore all, or see "optional" note below)
.idea/
*.iml
# CMake
cmake-build-*/
# IntelliJ
out/
# JIRA plugin
atlassian-ide-plugin.xml
# Crashlytics plugin
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based HTTP Client
http-client.private.env.json

64
README.md Normal file
View File

@@ -0,0 +1,64 @@
# OpenAI MCP Server
An MCP server to fetch OpenAI usage data.
## Configuration
This server can authenticate in two ways:
### 1. Automated Authentication (Recommended)
Provide your OpenAI login credentials. The server will launch a headless browser to log in and retrieve session tokens.
- `OPENAI_EMAIL`: Your OpenAI account email.
- `OPENAI_PASSWORD`: Your OpenAI account password.
- `OPENAI_TOTP_SECRET`: Your 2FA secret (if 2FA is enabled).
### 2. Manual Authentication (Fallback)
Manually provide tokens if automated login fails.
- `OPENAI_AUTH_TOKEN`: The access token (from `https://chatgpt.com/api/auth/session`).
- `OPENAI_COOKIE`: The full `Cookie` string from your browser session.
## Usage
### Automated Auth
```bash
export OPENAI_EMAIL="your_email@example.com"
export OPENAI_PASSWORD="your_password"
export OPENAI_TOTP_SECRET="your_totp_secret"
node build/index.js
```
### Manual Auth
```bash
export OPENAI_AUTH_TOKEN="your_token_here"
export OPENAI_COOKIE="your_cookie_string_here"
node build/index.js
```
### integrating with MCP Clients
Add to your MCP configuration:
```json
{
"mcpServers": {
"openai": {
"command": "node",
"args": ["/path/to/openai-mcp/build/index.js"],
"env": {
"OPENAI_AUTH_TOKEN": "your_token_here",
"OPENAI_COOKIE": "your_cookie_string_here"
}
}
}
}
```
## Tools
- `get_usage`: Fetches the current usage statistics from OpenAI.

3393
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "openai-mcp",
"version": "1.0.0",
"description": "MCP server for OpenAI usage integration",
"type": "module",
"bin": {
"openai-mcp": "./build/index.js"
},
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"start": "node build/index.js",
"dev": "tsx src/index.ts"
},
"keywords": [
"mcp",
"openai",
"usage"
],
"author": "",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.20.2",
"axios": "^1.12.2",
"otpauth": "^9.5.0",
"puppeteer": "^24.37.2",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^24.9.1",
"tsx": "^4.20.6",
"typescript": "^5.9.3"
}
}

482
src/index.ts Normal file
View File

@@ -0,0 +1,482 @@
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import axios, { AxiosInstance } from "axios";
import puppeteer from "puppeteer-extra";
import StealthPlugin from "puppeteer-extra-plugin-stealth";
import { Page } from "puppeteer";
// Add stealth plugin to puppeteer
const puppeteerExtra = puppeteer.default;
puppeteerExtra.use(StealthPlugin());
import * as OTPAuth from "otpauth";
class AutomatedAuth {
private email?: string;
private password?: string;
private totpSecret?: string;
private browser: any;
private page: Page | null = null;
private sessionToken: string | null = null;
private accessToken: string | null = null;
constructor(email?: string, password?: string, totpSecret?: string) {
this.email = email;
this.password = password;
this.totpSecret = totpSecret;
}
async loginAndGetToken(): Promise<{ accessToken: string; cookie: string }> {
if (!this.email || !this.password) {
throw new Error("Email and password are required for automated authentication.");
}
console.error("Launching browser for authentication...");
this.browser = await puppeteerExtra.launch({
headless: true, // headless: "new" is deprecated, true is preferred now
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
try {
this.page = await this.browser.newPage();
await this.page!.setViewport({ width: 1280, height: 800 });
console.error("Navigating to login page...");
await this.page!.goto("https://chatgpt.com/auth/login", {
waitUntil: "networkidle2",
});
// Click "Log in" button if present (sometimes it lands on a landing page)
const loginButtonSelector = 'button[data-testid="login-button"]';
if (await this.page!.$(loginButtonSelector)) {
await this.page!.click(loginButtonSelector);
await this.page!.waitForNavigation({ waitUntil: "networkidle0" });
}
// Check for email input
const emailInputSelector = 'input[name="email"], input[name="username"]'; // Generic selector attempt
try {
await this.page!.waitForSelector(emailInputSelector, { timeout: 10000 });
} catch (e) {
// Maybe already logged in or different flow?
console.error("Could not find email input, checking if already logged in...");
}
// Check if we are already on the password page (email filled and readonly)
const emailReadonlySelector = 'input[name="username"][readonly]';
if (await this.page!.$(emailReadonlySelector)) {
console.error("Email already filled, proceeding to password...");
} else {
// Enter email
console.error("Entering email...");
await this.page!.type(emailInputSelector, this.email);
// Try to find the continue button more aggressively
const continueBtnSelectors = [
'button[value="validate"]',
'button[name="intent"][value="validate"]',
'button.continue-btn',
'button[type="submit"]'
];
let clicked = false;
for (const selector of continueBtnSelectors) {
try {
const btn = await this.page!.$(selector);
if (btn) {
console.error(`Found continue button with selector: ${selector}`);
await btn.click();
clicked = true;
// Wait for navigation or change in form
try { await this.page!.waitForNavigation({ timeout: 5000 }); } catch (e) { }
break;
}
} catch (e) { }
}
if (!clicked) {
console.error("Could not find a continue button, trying Enter key...");
await this.page!.keyboard.press("Enter");
try { await this.page!.waitForNavigation({ timeout: 5000 }); } catch (e) { }
}
}
// Wait for password input - based on HTML provided it might be 'input[name="current-password"]' or 'input[type="password"]'
const passwordInputSelector = 'input[type="password"], input[name="current-password"], input[name="password"]';
try {
// Increase timeout to 20s
await this.page!.waitForSelector(passwordInputSelector, { timeout: 20000 });
} catch (e) {
console.error("Password input not found.");
// Debug: Print page title and some body text
const title = await this.page!.title();
const content = await this.page!.evaluate(() => document.body.innerText.substring(0, 500));
console.error(`Debug - Title: ${title}`);
console.error(`Debug - Content Preview: ${content.replace(/\n/g, ' ')}`);
throw new Error(`Stuck on email page. Title: ${title}`);
}
console.error("Entering password...");
// Use a short delay before typing to ensure focusibility
await new Promise(r => setTimeout(r, 500));
await this.page!.type(passwordInputSelector, this.password);
// Click continue again after password - same button selector likely
const submitBtnSelectors = [
'button[value="validate"]',
'button[name="intent"][value="validate"]',
'button[type="submit"]'
];
let clicked = false;
for (const selector of submitBtnSelectors) {
try {
const btn = await this.page!.$(selector);
if (btn) {
console.error(`Found submit button with selector: ${selector}`);
await btn.click();
clicked = true;
break;
}
} catch (e) { }
}
if (!clicked) {
await this.page!.keyboard.press("Enter");
}
console.error("Waiting for navigation or 2FA challenge...");
// Wait a bit to see if we get redirected or if 2FA appears
try {
await this.page!.waitForNavigation({ waitUntil: "networkidle2", timeout: 5000 });
} catch (e) {
// Might be strictly waiting for 2FA input without nav
}
// Check for 2FA input
const totpInputSelector = 'input[type="text"][inputmode="numeric"]'; // Often 6 digit input
// Or typically they might have specific classes/IDs.
// Let's try to detect if we are on a 2FA page by looking for text "authentication app" or similar
let is2FA = false;
try {
if (await this.page!.$(totpInputSelector)) {
is2FA = true;
} else {
// Check page content
const content = await this.page!.content();
if (content.includes("Enter code") || content.includes("authenticator app")) {
is2FA = true;
}
}
} catch (e) { }
if (is2FA) {
console.error("2FA challenge detected.");
if (!this.totpSecret) {
throw new Error("2FA is required but OPENAI_TOTP_SECRET is not set.");
}
console.error("Generating TOTP code...");
const totp = new OTPAuth.TOTP({
algorithm: "SHA1",
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(this.totpSecret),
});
const code = totp.generate();
console.error(`Entering TOTP code: ${code}`);
await this.page!.waitForSelector(totpInputSelector);
await this.page!.type(totpInputSelector, code);
await this.page!.keyboard.press("Enter");
await this.page!.waitForNavigation({ waitUntil: "networkidle2", timeout: 30000 });
}
console.error("Waiting for final navigation...");
// Ensure we are fully logged in
try {
await this.page!.waitForNavigation({ waitUntil: "networkidle2", timeout: 5000 });
} catch (e) { }
// Check if we are logged in by looking for specific cookies or URL
// access token is usually in the __Secure-next-auth.session-token cookie
// but the API also uses Authorization: Bearer <accessToken>
// The access token for the API is retrieved via /api/auth/session usually.
console.error("Waiting for session to stabilize...");
// Let's try to grab the session from the API
const sessionResponse = await this.page!.evaluate(async () => {
try {
const res = await fetch("https://chatgpt.com/api/auth/session");
if (res.ok) {
return await res.json();
}
} catch (e) {
return null;
}
return null;
});
if (sessionResponse && sessionResponse.accessToken) {
this.accessToken = sessionResponse.accessToken;
console.error("Successfully retrieved access token!");
} else {
console.error("Could not retrieve access token via fetch, trying cookies...");
// Fallback to cookies
}
const cookies = await this.page!.cookies();
const cookieString = cookies.map(c => `${c.name}=${c.value}`).join("; ");
// If we didn't get access token from fetch, we might need to rely purely on cookies
// Note: usage endpoint often requires the Bearer token which is the accessToken from the session object.
if (!this.accessToken) {
// Try one more time to get session
// Maybe we need to wait a bit more?
await new Promise(r => setTimeout(r, 2000));
const sessionResponseRetry = await this.page!.evaluate(async () => {
try {
const res = await fetch("https://chatgpt.com/api/auth/session");
if (res.ok) return await res.json();
} catch (e) { return null; }
});
if (sessionResponseRetry?.accessToken) {
this.accessToken = sessionResponseRetry.accessToken;
}
}
if (!this.accessToken) {
throw new Error("Failed to retrieve access token. Login might have failed or required captcha.");
}
return {
accessToken: this.accessToken,
cookie: cookieString
};
} catch (error) {
console.error("Authentication failed:", error);
// Capture screenshot for debugging
await this.page?.screenshot({ path: "auth-failure.png" });
throw error;
} finally {
await this.browser.close();
}
}
}
class OpenAiMcpServer {
private server: Server;
private axiosInstance: AxiosInstance | null = null;
private automatedAuth: AutomatedAuth | null = null;
private token: string | null = null;
private cookie: string | null = null;
constructor() {
this.server = new Server(
{
name: "openai-mcp-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
const email = process.env.OPENAI_EMAIL;
const password = process.env.OPENAI_PASSWORD;
const cookie = process.env.OPENAI_COOKIE;
const authToken = process.env.OPENAI_AUTH_TOKEN;
const totpSecret = process.env.OPENAI_TOTP_SECRET;
if (email && password) {
this.automatedAuth = new AutomatedAuth(email, password, totpSecret);
} else if (cookie && authToken) {
// Manual mode
this.token = authToken;
this.cookie = cookie;
this.initAxios();
} else {
console.warn("Warning: No authentication credentials provided. Usage fetching will fail.");
}
this.setupHandlers();
this.setupErrorHandling();
}
private initAxios() {
if (!this.token || !this.cookie) return;
this.axiosInstance = axios.create({
baseURL: "https://chatgpt.com",
headers: {
"accept": "*/*",
"accept-language": "en-US,en;q=0.9",
"authorization": `Bearer ${this.token}`,
"cookie": this.cookie,
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
// Add other headers as needed, but standard ones might suffice if cookies are correct
},
});
}
private setupErrorHandling(): void {
this.server.onerror = (error) => {
console.error("[MCP Error]", error);
};
process.on("SIGINT", async () => {
await this.server.close();
process.exit(0);
});
}
private setupHandlers(): void {
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: this.getTools(),
};
});
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name } = request.params;
try {
switch (name) {
case "get_usage":
return await this.getUsage();
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error: any) {
return {
content: [{ type: "text", text: `Error: ${error.message}` }],
isError: true,
};
}
});
}
private getTools(): Tool[] {
return [
{
name: "get_usage",
description: "Fetch OpenAI usage data. If credentials are unset, tries to log in automatically using OPENAI_EMAIL and OPENAI_PASSWORD.",
inputSchema: {
type: "object",
properties: {},
},
},
];
}
private async getUsage() {
// If we don't have an axios instance (no manual creds), try to authenticate now
if (!this.axiosInstance && this.automatedAuth) {
try {
const { accessToken, cookie } = await this.automatedAuth.loginAndGetToken();
this.token = accessToken;
this.cookie = cookie;
this.initAxios();
} catch (e: any) {
throw new Error(`Automated authentication failed: ${e.message}`);
}
}
if (!this.axiosInstance) {
throw new Error("Authentication failed or credentials missing. Please providing OPENAI_EMAIL/PASSWORD or OPENAI_AUTH_TOKEN/COOKIE.");
}
try {
const response = await this.axiosInstance!.get("/backend-api/wham/usage");
const data = response.data;
// Transform data to be more readable
const formattedData = this.formatUsageData(data);
return {
content: [
{
type: "text",
text: JSON.stringify(formattedData, null, 2),
},
],
};
} catch (error: any) {
if (axios.isAxiosError(error)) {
// If 401/403, and using automated auth, maybe we should retry login?
// For now just error out.
throw new Error(`Failed to fetch usage: ${error.message} - Status: ${error.response?.status} - Data: ${JSON.stringify(error.response?.data)}`);
}
throw new Error(`Failed to fetch usage: ${error.message}`);
}
}
private formatUsageData(data: any): any {
if (!data) return data;
const formatTime = (seconds: number) => {
if (!seconds) return "0s";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const parts = [];
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
if (s > 0) parts.push(`${s}s`);
return parts.join(" ");
};
const formatDate = (timestamp: number) => {
if (!timestamp) return null;
return new Date(timestamp * 1000).toLocaleString();
};
const traverseAndFormat = (obj: any): any => {
if (Array.isArray(obj)) {
return obj.map(traverseAndFormat);
}
if (typeof obj === 'object' && obj !== null) {
const newObj: any = {};
for (const key in obj) {
newObj[key] = traverseAndFormat(obj[key]);
// Add formatted fields
if (key.endsWith('_at') && typeof obj[key] === 'number') {
newObj[`${key}_formatted`] = formatDate(obj[key]);
}
if (key.endsWith('_seconds') && typeof obj[key] === 'number') {
newObj[`${key}_readable`] = formatTime(obj[key]);
}
}
return newObj;
}
return obj;
};
return traverseAndFormat(data);
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error("OpenAI MCP Server running on stdio");
}
}
const server = new OpenAiMcpServer();
server.run().catch(console.error);

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}