openprofit 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +94 -2
- package/dist/cli.js +175 -0
- package/dist/lib.js +198 -0
- package/dist/mcp.js +151 -0
- package/package.json +33 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Raphael Morency
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,95 @@
|
|
|
1
|
-
#
|
|
1
|
+
# openprofit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The `openprofit` package is the command-line tool and local MCP server for [OpenProfit](https://openprofit.dev). Use it to sign in, connect revenue and cost providers with keys from your env files, and let coding agents work with your OpenProfit workspace without pasting keys into a chat. It needs Node.js 20 or later.
|
|
4
|
+
|
|
5
|
+
## Sign in
|
|
6
|
+
|
|
7
|
+
Run `login` once per server:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx openprofit login
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The command prints a code and opens your browser. After you approve the code, the CLI saves a token to `~/.config/openprofit/config.json` (or `$XDG_CONFIG_HOME/openprofit/config.json`) with file mode `0600`.
|
|
14
|
+
|
|
15
|
+
To sign in to a self-hosted instance, pass its URL:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npx openprofit login --url https://profit.example.com
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The config keeps one token per server URL, so hosted and self-hosted logins sit side by side. Commands pick the server from `--url`, then `OPENPROFIT_URL`, then the last server you signed in to, then `https://openprofit.dev`. Set `OPENPROFIT_TOKEN` to use a token without signing in, for example in CI.
|
|
22
|
+
|
|
23
|
+
`openprofit whoami` shows the workspace and server in use. `openprofit logout` deletes the saved token for that server.
|
|
24
|
+
|
|
25
|
+
## Connect a provider from an env file
|
|
26
|
+
|
|
27
|
+
The `connect` command reads the key from an env file and sends it to OpenProfit. It never prints the key, error messages included:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npx openprofit connect stripe --env-file .env --key STRIPE_RESTRICTED_KEY
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
On success it prints `Connected Stripe (<label>), first sync started`. The options:
|
|
34
|
+
|
|
35
|
+
- `--env-file path`: file to read variables from. Without it, the CLI reads the environment only
|
|
36
|
+
- `--key NAME`: env var that holds the provider's secret key, for providers with one secret field
|
|
37
|
+
- `--field field=NAME`: read any field from an env var. Repeat it for each field
|
|
38
|
+
- `--value field=text`: set a non-secret field, such as a team id. The CLI refuses secret fields here so keys stay out of your shell history
|
|
39
|
+
- `--product id`: assign the connection to a product
|
|
40
|
+
|
|
41
|
+
Providers with several fields take one `--field` per field:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
npx openprofit connect twilio --env-file .env \
|
|
45
|
+
--field accountSid=TWILIO_ACCOUNT_SID \
|
|
46
|
+
--field keySid=TWILIO_API_KEY_SID \
|
|
47
|
+
--field keySecret=TWILIO_API_KEY_SECRET
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Use a restricted, read-only key when the provider offers one. The [integration pages](https://openprofit.dev/integrations) list the scopes each provider needs.
|
|
51
|
+
|
|
52
|
+
## Use OpenProfit from a coding agent
|
|
53
|
+
|
|
54
|
+
`openprofit mcp` runs a Model Context Protocol (MCP) server over stdio. It forwards the tools of your OpenProfit workspace and replaces `connect_provider` with a local version. The agent passes env var names, and the server reads the values on your machine. No tool takes a key value.
|
|
55
|
+
|
|
56
|
+
When the agent calls `connect_provider` with only a provider, the server looks for known variable names such as `STRIPE_SECRET_KEY`, `OPENAI_ADMIN_KEY` and `VERCEL_TOKEN` in `.env` and `.env.local`. It reports the names it finds so the agent can confirm with you. If it finds none, it returns a one-time link where you enter the key in the browser.
|
|
57
|
+
|
|
58
|
+
Run `npx openprofit login` before you add the server. Without a token, every tool replies with that instruction.
|
|
59
|
+
|
|
60
|
+
### Claude Code
|
|
61
|
+
|
|
62
|
+
Add the server from your project directory:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
claude mcp add openprofit -- npx -y openprofit mcp
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Cursor
|
|
69
|
+
|
|
70
|
+
Add the server to `.cursor/mcp.json` in your project, or to `~/.cursor/mcp.json` for every project:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"mcpServers": {
|
|
75
|
+
"openprofit": {
|
|
76
|
+
"command": "npx",
|
|
77
|
+
"args": ["-y", "openprofit", "mcp"]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Claude Desktop
|
|
84
|
+
|
|
85
|
+
Open **Settings > Developer > Edit Config** and add the same `mcpServers` entry to `claude_desktop_config.json`.
|
|
86
|
+
|
|
87
|
+
### Env files outside the working directory
|
|
88
|
+
|
|
89
|
+
The server resolves `.env` and `.env.local` against the directory the client starts it in. If the agent finds no keys although your project has them, ask it to pass the absolute path of your env file as `env_file`.
|
|
90
|
+
|
|
91
|
+
For a self-hosted instance, add `"--url", "https://profit.example.com"` to `args`, or sign in to that instance last.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { connect, LOGIN_HINT, loadEnv, removeLogin, request, resolveServer, saveLogin, VERSION, } from "./lib.js";
|
|
6
|
+
const HELP = {
|
|
7
|
+
main: `Usage: openprofit <command> [--url <url>]
|
|
8
|
+
|
|
9
|
+
Commands:
|
|
10
|
+
login Sign in through the browser and save a token
|
|
11
|
+
logout Delete the saved token for this server
|
|
12
|
+
whoami Show the workspace and server in use
|
|
13
|
+
connect <provider> Connect a provider with a key from an env file
|
|
14
|
+
mcp Run the local MCP server over stdio
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
--url <url> Server to use. Default: OPENPROFIT_URL, then the last login,
|
|
18
|
+
then https://openprofit.dev
|
|
19
|
+
-h, --help Show help for a command
|
|
20
|
+
-v, --version Show the version
|
|
21
|
+
|
|
22
|
+
OPENPROFIT_TOKEN overrides the saved token.`,
|
|
23
|
+
login: `Usage: openprofit login [--url <url>]
|
|
24
|
+
|
|
25
|
+
Opens the browser to approve a code, then saves a token in
|
|
26
|
+
~/.config/openprofit/config.json.`,
|
|
27
|
+
logout: `Usage: openprofit logout [--url <url>]
|
|
28
|
+
|
|
29
|
+
Deletes the saved token for the server.`,
|
|
30
|
+
whoami: `Usage: openprofit whoami [--url <url>]
|
|
31
|
+
|
|
32
|
+
Shows the workspace and server the token belongs to.`,
|
|
33
|
+
connect: `Usage: openprofit connect <provider> [options]
|
|
34
|
+
|
|
35
|
+
Reads the provider's key from an env file or the environment and sends it to
|
|
36
|
+
OpenProfit. The CLI never prints the key.
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
--env-file <path> Read variables from this file. Default: the environment
|
|
40
|
+
--key <NAME> Env var that holds the provider's secret key
|
|
41
|
+
--field <field=NAME> Read a field from an env var (repeatable)
|
|
42
|
+
--value <field=text> Set a non-secret field, such as a team id (repeatable)
|
|
43
|
+
--product <id> Assign the connection to a product
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
openprofit connect stripe --env-file .env --key STRIPE_RESTRICTED_KEY`,
|
|
47
|
+
mcp: `Usage: openprofit mcp [--url <url>]
|
|
48
|
+
|
|
49
|
+
Runs an MCP server over stdio. It forwards OpenProfit's tools and replaces
|
|
50
|
+
connect_provider with a version that reads keys from local env files.
|
|
51
|
+
|
|
52
|
+
Claude Code:
|
|
53
|
+
claude mcp add openprofit -- npx -y openprofit mcp`,
|
|
54
|
+
};
|
|
55
|
+
function pairs(list, flag) {
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const item of list ?? []) {
|
|
58
|
+
const i = item.indexOf("=");
|
|
59
|
+
if (i < 1)
|
|
60
|
+
throw new Error(`${flag} takes name=value, got "${item}".`);
|
|
61
|
+
out[item.slice(0, i)] = item.slice(i + 1);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
function openBrowser(url) {
|
|
66
|
+
const cmd = process.platform === "darwin"
|
|
67
|
+
? "open"
|
|
68
|
+
: process.platform === "win32"
|
|
69
|
+
? "explorer"
|
|
70
|
+
: "xdg-open";
|
|
71
|
+
spawn(cmd, [url], { stdio: "ignore", detached: true })
|
|
72
|
+
.on("error", () => { })
|
|
73
|
+
.unref();
|
|
74
|
+
}
|
|
75
|
+
async function login(url) {
|
|
76
|
+
const server = { ...resolveServer(url), token: undefined };
|
|
77
|
+
const start = await request(server, "POST", "/api/cli/login");
|
|
78
|
+
if (start.status !== 200 || !start.data.device_code) {
|
|
79
|
+
throw new Error(`${server.url} didn't start a login (HTTP ${start.status}).`);
|
|
80
|
+
}
|
|
81
|
+
const { device_code, user_code, verification_url } = start.data;
|
|
82
|
+
if (!/^https?:\/\//.test(verification_url)) {
|
|
83
|
+
throw new Error(`${server.url} sent an invalid verification URL.`);
|
|
84
|
+
}
|
|
85
|
+
console.log(`Code: ${user_code}`);
|
|
86
|
+
console.log(`Confirm it at ${verification_url}`);
|
|
87
|
+
if (process.stdout.isTTY)
|
|
88
|
+
openBrowser(verification_url);
|
|
89
|
+
const deadline = Date.now() + (start.data.expires_in ?? 600) * 1000;
|
|
90
|
+
while (Date.now() < deadline) {
|
|
91
|
+
await sleep((start.data.interval ?? 5) * 1000);
|
|
92
|
+
const poll = await request(server, "POST", "/api/cli/login/poll", { device_code });
|
|
93
|
+
if (poll.status === 202)
|
|
94
|
+
continue;
|
|
95
|
+
if (poll.status === 410)
|
|
96
|
+
break;
|
|
97
|
+
if (poll.status !== 200 || !poll.data.token || !poll.data.workspace) {
|
|
98
|
+
throw new Error(`Login failed (HTTP ${poll.status}).`);
|
|
99
|
+
}
|
|
100
|
+
saveLogin(server.url, poll.data.token, poll.data.workspace);
|
|
101
|
+
console.log(`Logged in to ${poll.data.workspace.name} (${poll.data.workspace.slug}) on ${server.url}`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
throw new Error("The code expired. Run `openprofit login` again.");
|
|
105
|
+
}
|
|
106
|
+
async function whoami(url) {
|
|
107
|
+
const server = resolveServer(url);
|
|
108
|
+
if (!server.token)
|
|
109
|
+
throw new Error(`Not logged in to ${server.url}. ${LOGIN_HINT}`);
|
|
110
|
+
// Any authenticated call works; this one fails fast on a revoked token.
|
|
111
|
+
await request(server, "GET", "/api/providers");
|
|
112
|
+
const who = server.workspace
|
|
113
|
+
? `${server.workspace.name} (${server.workspace.slug})`
|
|
114
|
+
: "OPENPROFIT_TOKEN";
|
|
115
|
+
console.log(`${who} on ${server.url}`);
|
|
116
|
+
}
|
|
117
|
+
async function main() {
|
|
118
|
+
const { values: flags, positionals } = parseArgs({
|
|
119
|
+
allowPositionals: true,
|
|
120
|
+
options: {
|
|
121
|
+
url: { type: "string" },
|
|
122
|
+
help: { type: "boolean", short: "h" },
|
|
123
|
+
version: { type: "boolean", short: "v" },
|
|
124
|
+
"env-file": { type: "string" },
|
|
125
|
+
key: { type: "string" },
|
|
126
|
+
field: { type: "string", multiple: true },
|
|
127
|
+
value: { type: "string", multiple: true },
|
|
128
|
+
product: { type: "string" },
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
const [command, ...args] = positionals;
|
|
132
|
+
if (flags.version)
|
|
133
|
+
return console.log(VERSION);
|
|
134
|
+
if (!command || flags.help) {
|
|
135
|
+
return console.log(HELP[command ?? "main"] ?? HELP.main);
|
|
136
|
+
}
|
|
137
|
+
switch (command) {
|
|
138
|
+
case "login":
|
|
139
|
+
return login(flags.url);
|
|
140
|
+
case "logout": {
|
|
141
|
+
const { url } = resolveServer(flags.url);
|
|
142
|
+
return console.log(removeLogin(url) ? `Logged out of ${url}` : `Not logged in to ${url}`);
|
|
143
|
+
}
|
|
144
|
+
case "whoami":
|
|
145
|
+
return whoami(flags.url);
|
|
146
|
+
case "connect": {
|
|
147
|
+
if (!args[0])
|
|
148
|
+
return console.log(HELP.connect);
|
|
149
|
+
const server = resolveServer(flags.url);
|
|
150
|
+
if (!server.token)
|
|
151
|
+
throw new Error(LOGIN_HINT);
|
|
152
|
+
const file = flags["env-file"];
|
|
153
|
+
const message = await connect(server, {
|
|
154
|
+
provider: args[0],
|
|
155
|
+
env: loadEnv(file ? [file] : []),
|
|
156
|
+
envLabel: file ? `${file} or the environment` : "the environment",
|
|
157
|
+
key: flags.key,
|
|
158
|
+
fields: pairs(flags.field, "--field"),
|
|
159
|
+
values: pairs(flags.value, "--value"),
|
|
160
|
+
productId: flags.product,
|
|
161
|
+
});
|
|
162
|
+
return console.log(message);
|
|
163
|
+
}
|
|
164
|
+
case "mcp": {
|
|
165
|
+
const { serveMcp } = await import("./mcp.js");
|
|
166
|
+
return serveMcp(resolveServer(flags.url));
|
|
167
|
+
}
|
|
168
|
+
default:
|
|
169
|
+
throw new Error(`Unknown command "${command}". Run \`openprofit --help\`.`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
main().catch((e) => {
|
|
173
|
+
console.error(`openprofit: ${e.message}`);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
});
|
package/dist/lib.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
5
|
+
export const LOGIN_HINT = "Run `npx openprofit login` first.";
|
|
6
|
+
const CONFIG_PATH = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "openprofit", "config.json");
|
|
7
|
+
function readConfig() {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return { servers: {} };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function writeConfig(config) {
|
|
16
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
|
|
17
|
+
writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, {
|
|
18
|
+
mode: 0o600,
|
|
19
|
+
});
|
|
20
|
+
// `mode` only applies when the file is created.
|
|
21
|
+
chmodSync(CONFIG_PATH, 0o600);
|
|
22
|
+
}
|
|
23
|
+
// --url, then OPENPROFIT_URL, then the last login, then the hosted app.
|
|
24
|
+
export function resolveServer(flagUrl) {
|
|
25
|
+
const config = readConfig();
|
|
26
|
+
const url = (flagUrl ||
|
|
27
|
+
process.env.OPENPROFIT_URL ||
|
|
28
|
+
config.current ||
|
|
29
|
+
"https://openprofit.dev").replace(/\/+$/, "");
|
|
30
|
+
const entry = config.servers[url];
|
|
31
|
+
return {
|
|
32
|
+
url,
|
|
33
|
+
token: process.env.OPENPROFIT_TOKEN || entry?.token,
|
|
34
|
+
workspace: entry?.workspace,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function saveLogin(url, token, workspace) {
|
|
38
|
+
const config = readConfig();
|
|
39
|
+
config.servers[url] = { url, token, workspace };
|
|
40
|
+
config.current = url;
|
|
41
|
+
writeConfig(config);
|
|
42
|
+
}
|
|
43
|
+
export function removeLogin(url) {
|
|
44
|
+
const config = readConfig();
|
|
45
|
+
if (!config.servers[url])
|
|
46
|
+
return false;
|
|
47
|
+
delete config.servers[url];
|
|
48
|
+
if (config.current === url)
|
|
49
|
+
delete config.current;
|
|
50
|
+
writeConfig(config);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
// ---- HTTP
|
|
54
|
+
export async function request(server, method, path, body) {
|
|
55
|
+
const res = await fetch(server.url + path, {
|
|
56
|
+
method,
|
|
57
|
+
headers: {
|
|
58
|
+
"content-type": "application/json",
|
|
59
|
+
...(server.token && { authorization: `Bearer ${server.token}` }),
|
|
60
|
+
},
|
|
61
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
62
|
+
}).catch(() => {
|
|
63
|
+
throw new Error(`Can't reach ${server.url}.`);
|
|
64
|
+
});
|
|
65
|
+
if (res.status === 401) {
|
|
66
|
+
throw new Error(`${server.url} rejected the token. ${LOGIN_HINT}`);
|
|
67
|
+
}
|
|
68
|
+
const data = (await res.json().catch(() => ({})));
|
|
69
|
+
return { status: res.status, data };
|
|
70
|
+
}
|
|
71
|
+
// ---- Env files
|
|
72
|
+
// Parses dotenv syntax: `export` prefix, comments, single, double and
|
|
73
|
+
// backtick quotes (quoted values may span lines; double quotes expand \n,
|
|
74
|
+
// \r, \t, \" and \\). An unquoted value ends at a ` #` comment.
|
|
75
|
+
export function parseEnv(src) {
|
|
76
|
+
const out = {};
|
|
77
|
+
const line = /^[ \t]*(?:export[ \t]+)?([A-Za-z_][\w.-]*)[ \t]*=[ \t]*("(?:\\[\s\S]|[^"\\])*"|'[^']*'|`[^`]*`|[^\r\n]*)/gm;
|
|
78
|
+
const escapes = { n: "\n", r: "\r", t: "\t" };
|
|
79
|
+
for (const [, key, raw] of src.matchAll(line)) {
|
|
80
|
+
const quote = raw[0];
|
|
81
|
+
if (quote === '"') {
|
|
82
|
+
out[key] = raw
|
|
83
|
+
.slice(1, -1)
|
|
84
|
+
.replace(/\\(.)/g, (_, c) => escapes[c] ?? c);
|
|
85
|
+
}
|
|
86
|
+
else if (quote === "'" || quote === "`") {
|
|
87
|
+
out[key] = raw.slice(1, -1);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
out[key] = raw.replace(/(^|[ \t]+)#.*$/, "").trim();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
// Variables from the files (later files win) over the process environment.
|
|
96
|
+
// `optional` skips files that don't exist.
|
|
97
|
+
export function loadEnv(files, optional = false) {
|
|
98
|
+
const env = { ...process.env };
|
|
99
|
+
for (const file of files) {
|
|
100
|
+
try {
|
|
101
|
+
Object.assign(env, parseEnv(readFileSync(file, "utf8")));
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
if (!(optional && e.code === "ENOENT")) {
|
|
105
|
+
throw new Error(`Can't read ${file}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return env;
|
|
110
|
+
}
|
|
111
|
+
// Env var names that usually hold each provider's secret key.
|
|
112
|
+
export const KNOWN_ENV = {
|
|
113
|
+
stripe: ["STRIPE_RESTRICTED_KEY", "STRIPE_SECRET_KEY", "STRIPE_API_KEY"],
|
|
114
|
+
polar: ["POLAR_ACCESS_TOKEN"],
|
|
115
|
+
openai: ["OPENAI_ADMIN_KEY", "OPENAI_API_KEY"],
|
|
116
|
+
anthropic: ["ANTHROPIC_ADMIN_KEY"],
|
|
117
|
+
vercel: ["VERCEL_TOKEN"],
|
|
118
|
+
cloudflare: ["CLOUDFLARE_API_TOKEN"],
|
|
119
|
+
railway: ["RAILWAY_TOKEN", "RAILWAY_API_TOKEN"],
|
|
120
|
+
};
|
|
121
|
+
export async function getProvider(server, id) {
|
|
122
|
+
const { status, data } = await request(server, "GET", "/api/providers");
|
|
123
|
+
if (status !== 200 || !Array.isArray(data)) {
|
|
124
|
+
throw new Error(`Couldn't list providers (HTTP ${status}).`);
|
|
125
|
+
}
|
|
126
|
+
const provider = data.find((p) => p.id === id.toLowerCase());
|
|
127
|
+
if (!provider) {
|
|
128
|
+
const ids = data.map((p) => p.id).join(", ");
|
|
129
|
+
throw new Error(`Unknown provider "${id}". Available: ${ids}.`);
|
|
130
|
+
}
|
|
131
|
+
return provider;
|
|
132
|
+
}
|
|
133
|
+
export function describeFields(provider) {
|
|
134
|
+
return provider.fields
|
|
135
|
+
.map((f) => `${f.name}${f.secret ? " (secret)" : ""}${f.optional ? " (optional)" : ""}`)
|
|
136
|
+
.join(", ");
|
|
137
|
+
}
|
|
138
|
+
// Builds the credentials from env vars and POSTs them. Error messages name
|
|
139
|
+
// fields and env vars but never include a value.
|
|
140
|
+
export async function connect(server, input) {
|
|
141
|
+
const provider = await getProvider(server, input.provider);
|
|
142
|
+
const field = (name) => {
|
|
143
|
+
const f = provider.fields.find((f) => f.name === name);
|
|
144
|
+
if (!f) {
|
|
145
|
+
throw new Error(`${provider.name} has no field "${name}". Fields: ${describeFields(provider)}.`);
|
|
146
|
+
}
|
|
147
|
+
return f;
|
|
148
|
+
};
|
|
149
|
+
const fields = { ...input.fields };
|
|
150
|
+
if (input.key) {
|
|
151
|
+
const secret = provider.fields.filter((f) => f.secret);
|
|
152
|
+
if (secret.length !== 1) {
|
|
153
|
+
throw new Error(`${provider.name} has ${secret.length} secret fields, so --key is ambiguous. Map each one with --field. Fields: ${describeFields(provider)}.`);
|
|
154
|
+
}
|
|
155
|
+
fields[secret[0].name] ??= input.key;
|
|
156
|
+
}
|
|
157
|
+
const credentials = {};
|
|
158
|
+
for (const [name, envName] of Object.entries(fields)) {
|
|
159
|
+
field(name);
|
|
160
|
+
const value = input.env[envName];
|
|
161
|
+
if (!value)
|
|
162
|
+
throw new Error(`${envName} is not set in ${input.envLabel}.`);
|
|
163
|
+
credentials[name] = value;
|
|
164
|
+
}
|
|
165
|
+
for (const [name, value] of Object.entries(input.values)) {
|
|
166
|
+
if (field(name).secret) {
|
|
167
|
+
throw new Error(`${name} is a secret field. Pass the name of an env var that holds it instead of the value.`);
|
|
168
|
+
}
|
|
169
|
+
credentials[name] = value;
|
|
170
|
+
}
|
|
171
|
+
const missing = provider.fields.filter((f) => !f.optional && !credentials[f.name]);
|
|
172
|
+
if (missing.length) {
|
|
173
|
+
throw new Error(`Missing ${missing.map((f) => f.name).join(", ")}. ${provider.name} fields: ${describeFields(provider)}.`);
|
|
174
|
+
}
|
|
175
|
+
const { status, data } = await request(server, "POST", "/api/connections", {
|
|
176
|
+
provider: provider.id,
|
|
177
|
+
credentials,
|
|
178
|
+
...(input.productId && { product_id: input.productId }),
|
|
179
|
+
});
|
|
180
|
+
if (status !== 201 || !data.connection) {
|
|
181
|
+
const secrets = provider.fields
|
|
182
|
+
.filter((f) => f.secret && credentials[f.name])
|
|
183
|
+
.map((f) => credentials[f.name]);
|
|
184
|
+
throw new Error(scrub(data.error ?? `Connecting failed (HTTP ${status}).`, secrets));
|
|
185
|
+
}
|
|
186
|
+
return `Connected ${provider.name} (${data.connection.label}), first sync started`;
|
|
187
|
+
}
|
|
188
|
+
// Drops a server message that quotes 8 or more characters of a secret.
|
|
189
|
+
function scrub(message, secrets) {
|
|
190
|
+
const leaks = secrets.some((s) => {
|
|
191
|
+
for (let i = 0; i + 8 <= s.length; i++) {
|
|
192
|
+
if (message.includes(s.slice(i, i + 8)))
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
return message.includes(s);
|
|
196
|
+
});
|
|
197
|
+
return leaks ? "OpenProfit rejected the credentials." : message;
|
|
198
|
+
}
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Local stdio MCP server. It forwards the remote OpenProfit tools and
|
|
2
|
+
// replaces connect_provider with a version that reads keys from env files
|
|
3
|
+
// on this machine, so key values never pass through the agent.
|
|
4
|
+
import { Client, UrlElicitationRequiredError as RemoteUrlElicitationRequired, StreamableHTTPClientTransport, } from "@modelcontextprotocol/client";
|
|
5
|
+
import { fromJsonSchema, McpServer, UrlElicitationRequiredError, } from "@modelcontextprotocol/server";
|
|
6
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { connect, describeFields, getProvider, KNOWN_ENV, LOGIN_HINT, loadEnv, VERSION, } from "./lib.js";
|
|
9
|
+
const INSTRUCTIONS = `OpenProfit shows revenue, costs and profit per product.
|
|
10
|
+
|
|
11
|
+
To add a provider such as Stripe or OpenAI, call connect_provider. Never ask the user to paste an API key into the chat: pass env var names and this server reads the values from env files on this machine. Suggest a restricted or read-only key when the provider offers one. If connect_provider finds no key, it returns a one-time link. Show the link to the user, then call connection_status with its link_id until the connection appears.`;
|
|
12
|
+
const CONNECT_DESCRIPTION = `Connect a provider (stripe, openai, vercel...) to the OpenProfit workspace. Pass env var names, never key values: this server reads the values from local env files.
|
|
13
|
+
|
|
14
|
+
Call it with only \`provider\` first. It lists the known env vars it found in .env and .env.local, plus the provider's fields. After the user picks one, call it again with \`fields\`, such as {"key": "STRIPE_RESTRICTED_KEY"}. If it finds no key, it returns a one-time link where the user enters the key in the browser.`;
|
|
15
|
+
const text = (message, isError = false) => ({
|
|
16
|
+
content: [{ type: "text", text: message }],
|
|
17
|
+
isError,
|
|
18
|
+
});
|
|
19
|
+
// https://ts.sdk.modelcontextprotocol.io/v2/clients/connect.html
|
|
20
|
+
async function connectRemote(server) {
|
|
21
|
+
const client = new Client({ name: "openprofit-cli", version: VERSION });
|
|
22
|
+
await client.connect(new StreamableHTTPClientTransport(new URL(`${server.url}/mcp`), {
|
|
23
|
+
authProvider: { token: async () => server.token },
|
|
24
|
+
}));
|
|
25
|
+
const tools = [];
|
|
26
|
+
let cursor;
|
|
27
|
+
do {
|
|
28
|
+
const page = await client.listTools({ cursor });
|
|
29
|
+
tools.push(...page.tools);
|
|
30
|
+
cursor = page.nextCursor;
|
|
31
|
+
} while (cursor);
|
|
32
|
+
return { client, tools };
|
|
33
|
+
}
|
|
34
|
+
async function forward(remote, name, args) {
|
|
35
|
+
try {
|
|
36
|
+
return await remote.client.callTool({ name, arguments: args });
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
// Pass a URL elicitation (-32042) on to the host unchanged; McpServer
|
|
40
|
+
// rethrows this error instead of turning it into a tool error.
|
|
41
|
+
// https://ts.sdk.modelcontextprotocol.io/v2/servers/elicitation.html
|
|
42
|
+
if (e instanceof RemoteUrlElicitationRequired) {
|
|
43
|
+
throw new UrlElicitationRequiredError(e.elicitations, e.message);
|
|
44
|
+
}
|
|
45
|
+
throw e;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function serveMcp(server) {
|
|
49
|
+
// One remote connection per process, shared by every factory call.
|
|
50
|
+
const remote = server.token
|
|
51
|
+
? connectRemote(server).catch((e) => {
|
|
52
|
+
// stdout carries JSON-RPC, so log to stderr.
|
|
53
|
+
console.error(`openprofit: ${server.url}/mcp: ${e.message}`);
|
|
54
|
+
return undefined;
|
|
55
|
+
})
|
|
56
|
+
: undefined;
|
|
57
|
+
// https://ts.sdk.modelcontextprotocol.io/v2/serving/stdio.html
|
|
58
|
+
const handle = serveStdio(async () => {
|
|
59
|
+
const mcp = new McpServer({ name: "openprofit", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
60
|
+
const r = await remote;
|
|
61
|
+
// ponytail: tools are listed once at startup; restart the server to
|
|
62
|
+
// pick up new remote tools, or handle tools/list_changed if that matters.
|
|
63
|
+
for (const tool of r?.tools ?? []) {
|
|
64
|
+
if (tool.name === "connect_provider")
|
|
65
|
+
continue;
|
|
66
|
+
mcp.registerTool(tool.name, {
|
|
67
|
+
title: tool.title,
|
|
68
|
+
description: tool.description,
|
|
69
|
+
inputSchema: fromJsonSchema(tool.inputSchema),
|
|
70
|
+
outputSchema: tool.outputSchema &&
|
|
71
|
+
fromJsonSchema(tool.outputSchema),
|
|
72
|
+
annotations: tool.annotations,
|
|
73
|
+
}, (args) => forward(r, tool.name, args));
|
|
74
|
+
}
|
|
75
|
+
// Tool annotations: https://ts.sdk.modelcontextprotocol.io/v2/servers/tools.html
|
|
76
|
+
mcp.registerTool("connect_provider", {
|
|
77
|
+
title: "Connect a provider",
|
|
78
|
+
description: CONNECT_DESCRIPTION,
|
|
79
|
+
inputSchema: z.object({
|
|
80
|
+
provider: z
|
|
81
|
+
.string()
|
|
82
|
+
.describe("Provider id, such as stripe or openai"),
|
|
83
|
+
env_file: z
|
|
84
|
+
.string()
|
|
85
|
+
.optional()
|
|
86
|
+
.describe("Env file to read, relative to the working directory. Default: .env and .env.local"),
|
|
87
|
+
fields: z
|
|
88
|
+
.record(z.string(), z.string())
|
|
89
|
+
.optional()
|
|
90
|
+
.describe('Provider field name to env var name, such as {"key": "STRIPE_RESTRICTED_KEY"}'),
|
|
91
|
+
values: z
|
|
92
|
+
.record(z.string(), z.string())
|
|
93
|
+
.optional()
|
|
94
|
+
.describe("Values for non-secret fields, such as a team id. Secret fields are rejected here."),
|
|
95
|
+
product_id: z
|
|
96
|
+
.string()
|
|
97
|
+
.optional()
|
|
98
|
+
.describe("Product to assign the connection to"),
|
|
99
|
+
}),
|
|
100
|
+
annotations: {
|
|
101
|
+
readOnlyHint: false,
|
|
102
|
+
destructiveHint: false,
|
|
103
|
+
idempotentHint: false,
|
|
104
|
+
openWorldHint: true,
|
|
105
|
+
},
|
|
106
|
+
}, async ({ provider, env_file, fields, values, product_id }) => {
|
|
107
|
+
if (!server.token)
|
|
108
|
+
return text(LOGIN_HINT, true);
|
|
109
|
+
const files = env_file ? [env_file] : [".env", ".env.local"];
|
|
110
|
+
const env = loadEnv(files, !env_file);
|
|
111
|
+
const where = `${files.join(", ")} or the environment`;
|
|
112
|
+
if (fields || values) {
|
|
113
|
+
const message = await connect(server, {
|
|
114
|
+
provider,
|
|
115
|
+
env,
|
|
116
|
+
envLabel: where,
|
|
117
|
+
fields: fields ?? {},
|
|
118
|
+
values: values ?? {},
|
|
119
|
+
productId: product_id,
|
|
120
|
+
});
|
|
121
|
+
return text(`${message}.`);
|
|
122
|
+
}
|
|
123
|
+
const p = await getProvider(server, provider);
|
|
124
|
+
const found = (KNOWN_ENV[p.id] ?? []).filter((name) => env[name]);
|
|
125
|
+
if (found.length) {
|
|
126
|
+
const secret = p.fields.find((f) => f.secret)?.name ?? "key";
|
|
127
|
+
return text(`Found ${found.join(", ")} in ${where}. ${p.name} fields: ${describeFields(p)}. Ask the user which variable to use, then call connect_provider again with fields {"${secret}": "<ENV_VAR>"}.`);
|
|
128
|
+
}
|
|
129
|
+
if (!r)
|
|
130
|
+
throw new Error(`Can't reach ${server.url}/mcp to create a link.`);
|
|
131
|
+
const result = await forward(r, "connect_provider", {
|
|
132
|
+
provider: p.id,
|
|
133
|
+
...(product_id && { product_id }),
|
|
134
|
+
});
|
|
135
|
+
if (result.isError)
|
|
136
|
+
return result;
|
|
137
|
+
return {
|
|
138
|
+
...result,
|
|
139
|
+
content: [
|
|
140
|
+
{
|
|
141
|
+
type: "text",
|
|
142
|
+
text: `No ${p.name} key in ${where}. Show the user this link to enter the key in the browser, then call connection_status with the link_id.`,
|
|
143
|
+
},
|
|
144
|
+
...result.content,
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
return mcp;
|
|
149
|
+
});
|
|
150
|
+
process.on("SIGINT", () => void handle.close());
|
|
151
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "openprofit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenProfit CLI and local MCP server",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://openprofit.dev",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/lobbystack/openprofit.git",
|
|
10
|
+
"directory": "packages/cli"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"bin": {
|
|
14
|
+
"openprofit": "dist/cli.js"
|
|
15
|
+
},
|
|
16
|
+
"files": ["dist"],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p .",
|
|
22
|
+
"test": "node --test test/",
|
|
23
|
+
"prepack": "tsc -p ."
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@modelcontextprotocol/client": "^2.3.1",
|
|
27
|
+
"@modelcontextprotocol/server": "^2.3.1",
|
|
28
|
+
"zod": "^4.6.5"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^22.20.5",
|
|
32
|
+
"typescript": "^6.0.3"
|
|
33
|
+
}
|
|
34
|
+
}
|