add usage tracker
This commit is contained in:
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal 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
64
README.md
Normal 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
3393
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal 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
482
src/index.ts
Normal 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
21
tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user