@mjquinlan2000/practicepanther-mcp 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 +130 -0
- package/dist/auth.d.ts +4 -0
- package/dist/auth.js +9 -0
- package/dist/auth.js.map +1 -0
- package/dist/chunk-A6QUURB5.js +234 -0
- package/dist/chunk-A6QUURB5.js.map +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1779 -0
- package/dist/server.js.map +1 -0
- package/openapi.json +11493 -0
- package/package.json +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Quinlan
|
|
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
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# @mjquinlan2000/practicepanther-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for the [PracticePanther](https://www.practicepanther.com/) legal practice management API. Exposes PracticePanther's REST API as MCP tools so AI assistants (Claude Desktop, etc.) can read data from PracticePanther accounts.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @mjquinlan2000/practicepanther-mcp
|
|
9
|
+
# or run directly
|
|
10
|
+
npx @mjquinlan2000/practicepanther-mcp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
### 1. Obtain API credentials
|
|
16
|
+
|
|
17
|
+
Register an OAuth2 application in PracticePanther to get a client ID and secret.
|
|
18
|
+
|
|
19
|
+
### 2. Set environment variables
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
export PP_CLIENT_ID=your_client_id
|
|
23
|
+
export PP_CLIENT_SECRET=your_client_secret
|
|
24
|
+
export PP_REDIRECT_URI=http://127.0.0.1:8080/callback
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### 3. Authenticate
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
# Initial OAuth2 authorization (opens browser)
|
|
31
|
+
yarn auth
|
|
32
|
+
|
|
33
|
+
# Refresh an expired token
|
|
34
|
+
yarn auth:refresh
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Tokens are persisted to `~/.config/practicepanther-mcp/tokens.json` and auto-refresh when the server runs.
|
|
38
|
+
|
|
39
|
+
Alternatively, set `PP_ACCESS_TOKEN` directly to skip the OAuth flow.
|
|
40
|
+
|
|
41
|
+
## MCP Client Configuration
|
|
42
|
+
|
|
43
|
+
Add to your MCP client config (e.g. Claude Desktop `claude_desktop_config.json`):
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"mcpServers": {
|
|
48
|
+
"practicepanther": {
|
|
49
|
+
"command": "npx",
|
|
50
|
+
"args": ["@mjquinlan2000/practicepanther-mcp"],
|
|
51
|
+
"env": {
|
|
52
|
+
"PP_CLIENT_ID": "your_client_id",
|
|
53
|
+
"PP_CLIENT_SECRET": "your_client_secret"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
For local development with `tsx`:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"mcpServers": {
|
|
65
|
+
"practicepanther": {
|
|
66
|
+
"command": "tsx",
|
|
67
|
+
"args": ["src/server.ts"],
|
|
68
|
+
"env": {
|
|
69
|
+
"PP_CLIENT_ID": "${PP_CLIENT_ID}",
|
|
70
|
+
"PP_CLIENT_SECRET": "${PP_CLIENT_SECRET}"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Available Tools
|
|
78
|
+
|
|
79
|
+
The server registers tools for the following PracticePanther entities. Each entity has a `get_<entity>` (by ID) and `list_<entities>` tool unless noted otherwise.
|
|
80
|
+
|
|
81
|
+
| Entity | Tools |
|
|
82
|
+
|--------|-------|
|
|
83
|
+
| Accounts | `get_account`, `list_accounts` |
|
|
84
|
+
| Bank Accounts | `get_bank_account`, `list_bank_accounts` |
|
|
85
|
+
| Call Logs | `get_call_log`, `list_call_logs` |
|
|
86
|
+
| Contacts | `get_contact`, `list_contacts` |
|
|
87
|
+
| Custom Fields | `get_custom_field`, `list_custom_fields_for_company`, `list_custom_fields_for_matter`, `list_custom_fields_for_contact` |
|
|
88
|
+
| Emails | `get_email`, `list_emails` |
|
|
89
|
+
| Events | `get_event`, `list_events` |
|
|
90
|
+
| Expense Categories | `get_expense_category`, `list_expense_categories` |
|
|
91
|
+
| Expenses | `get_expense`, `list_expenses` |
|
|
92
|
+
| Files | `get_file`, `download_file`, `list_files` |
|
|
93
|
+
| Flat Fees | `get_flat_fee`, `list_flat_fees` |
|
|
94
|
+
| Invoices | `get_invoice`, `list_invoices` |
|
|
95
|
+
| Items | `get_item`, `list_items` |
|
|
96
|
+
| Matters | `get_matter`, `list_matters` |
|
|
97
|
+
| Messages | `list_messages` |
|
|
98
|
+
| Notes | `get_note`, `list_notes` |
|
|
99
|
+
| Payments | `get_payment`, `list_payments` |
|
|
100
|
+
| Relationships | `get_relationship`, `list_relationships` |
|
|
101
|
+
| Tags | `list_account_tags`, `list_matter_tags`, `list_activity_tags` |
|
|
102
|
+
| Tasks | `get_task`, `list_tasks` |
|
|
103
|
+
| Time Entries | `get_time_entry`, `list_time_entries` |
|
|
104
|
+
| Users | `get_me`, `get_user`, `list_users` |
|
|
105
|
+
|
|
106
|
+
## Development
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
yarn start # Run server locally (stdio transport)
|
|
110
|
+
yarn build # Bundle with tsup
|
|
111
|
+
yarn typecheck # Type-check with tsc --noEmit
|
|
112
|
+
yarn spec:generate # Fetch Swagger spec, convert to OpenAPI 3, regenerate typed client
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Project Structure
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
src/
|
|
119
|
+
├── server.ts # MCP server entry point — registers all tools
|
|
120
|
+
├── schemas.ts # Zod schemas for shaping API responses
|
|
121
|
+
├── auth.ts # OAuth2 config (delegates to shared oauth utility)
|
|
122
|
+
├── pp.ts # Configures generated HTTP client with base URL + auth
|
|
123
|
+
└── client/ # Auto-generated typed API client (do not edit)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Adding a New Entity
|
|
127
|
+
|
|
128
|
+
1. Import the SDK functions from `./client/index.js`
|
|
129
|
+
2. Define a Zod schema in `schemas.ts`
|
|
130
|
+
3. Register `get_` and `list_` tools in `server.ts`
|
package/dist/auth.d.ts
ADDED
package/dist/auth.js
ADDED
package/dist/auth.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// ../shared/dist/oauth.js
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
import { randomBytes } from "crypto";
|
|
4
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
5
|
+
import { createServer } from "http";
|
|
6
|
+
import { homedir } from "os";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
function isRefreshable(tokens) {
|
|
9
|
+
return "refresh_token" in tokens;
|
|
10
|
+
}
|
|
11
|
+
function createAuth(config) {
|
|
12
|
+
const configDir = join(homedir(), ".config", config.name);
|
|
13
|
+
const tokensPath = join(configDir, "tokens.json");
|
|
14
|
+
async function readTokens() {
|
|
15
|
+
try {
|
|
16
|
+
const data = await readFile(tokensPath, "utf-8");
|
|
17
|
+
return JSON.parse(data);
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function writeTokens(tokens) {
|
|
23
|
+
await mkdir(configDir, { recursive: true });
|
|
24
|
+
await writeFile(tokensPath, JSON.stringify(tokens, null, 2), {
|
|
25
|
+
mode: 384
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function openBrowser(url) {
|
|
29
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
30
|
+
execSync(`${cmd} '${url}'`);
|
|
31
|
+
}
|
|
32
|
+
async function exchangeCode(code, redirectUri, clientId, clientSecret) {
|
|
33
|
+
const res = await fetch(config.tokenUrl, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
36
|
+
body: new URLSearchParams({
|
|
37
|
+
grant_type: "authorization_code",
|
|
38
|
+
code,
|
|
39
|
+
redirect_uri: redirectUri,
|
|
40
|
+
client_id: clientId,
|
|
41
|
+
client_secret: clientSecret
|
|
42
|
+
})
|
|
43
|
+
});
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const text = await res.text();
|
|
46
|
+
throw new Error(`Token exchange failed (${res.status}): ${text}`);
|
|
47
|
+
}
|
|
48
|
+
if (config.supportsRefresh) {
|
|
49
|
+
const data2 = await res.json();
|
|
50
|
+
return {
|
|
51
|
+
access_token: data2.access_token,
|
|
52
|
+
refresh_token: data2.refresh_token,
|
|
53
|
+
expires_at: Date.now() + data2.expires_in * 1e3
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const data = await res.json();
|
|
57
|
+
return { access_token: data.access_token };
|
|
58
|
+
}
|
|
59
|
+
async function refreshAccessToken(refreshToken, clientId, clientSecret) {
|
|
60
|
+
const res = await fetch(config.tokenUrl, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
63
|
+
body: new URLSearchParams({
|
|
64
|
+
grant_type: "refresh_token",
|
|
65
|
+
refresh_token: refreshToken,
|
|
66
|
+
client_id: clientId,
|
|
67
|
+
client_secret: clientSecret
|
|
68
|
+
})
|
|
69
|
+
});
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
throw new Error("Run 'yarn auth' to re-authenticate.");
|
|
72
|
+
}
|
|
73
|
+
const data = await res.json();
|
|
74
|
+
return {
|
|
75
|
+
access_token: data.access_token,
|
|
76
|
+
refresh_token: data.refresh_token,
|
|
77
|
+
expires_at: Date.now() + data.expires_in * 1e3
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
async function getAccessToken2() {
|
|
81
|
+
const envToken = process.env[`${config.envPrefix}_ACCESS_TOKEN`];
|
|
82
|
+
if (envToken)
|
|
83
|
+
return envToken;
|
|
84
|
+
const tokens = await readTokens();
|
|
85
|
+
if (!tokens) {
|
|
86
|
+
throw new Error(`No access token available. Set ${config.envPrefix}_ACCESS_TOKEN or run 'yarn auth'.`);
|
|
87
|
+
}
|
|
88
|
+
if (config.supportsRefresh && isRefreshable(tokens)) {
|
|
89
|
+
const fiveMinutes = 5 * 60 * 1e3;
|
|
90
|
+
if (tokens.expires_at - Date.now() < fiveMinutes) {
|
|
91
|
+
const clientId = process.env[`${config.envPrefix}_CLIENT_ID`];
|
|
92
|
+
const clientSecret = process.env[`${config.envPrefix}_CLIENT_SECRET`];
|
|
93
|
+
if (!clientId || !clientSecret) {
|
|
94
|
+
throw new Error(`Token is expiring and ${config.envPrefix}_CLIENT_ID/${config.envPrefix}_CLIENT_SECRET are not set for refresh. Run 'yarn auth'.`);
|
|
95
|
+
}
|
|
96
|
+
const refreshed = await refreshAccessToken(tokens.refresh_token, clientId, clientSecret);
|
|
97
|
+
await writeTokens(refreshed);
|
|
98
|
+
return refreshed.access_token;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return tokens.access_token;
|
|
102
|
+
}
|
|
103
|
+
async function authorize() {
|
|
104
|
+
const clientId = process.env[`${config.envPrefix}_CLIENT_ID`];
|
|
105
|
+
const clientSecret = process.env[`${config.envPrefix}_CLIENT_SECRET`];
|
|
106
|
+
if (!clientId)
|
|
107
|
+
throw new Error(`${config.envPrefix}_CLIENT_ID environment variable is required.`);
|
|
108
|
+
if (!clientSecret)
|
|
109
|
+
throw new Error(`${config.envPrefix}_CLIENT_SECRET environment variable is required.`);
|
|
110
|
+
const redirectUri = process.env[`${config.envPrefix}_REDIRECT_URI`];
|
|
111
|
+
if (!redirectUri)
|
|
112
|
+
throw new Error(`${config.envPrefix}_REDIRECT_URI environment variable is required.`);
|
|
113
|
+
const redirectUrl = new URL(redirectUri);
|
|
114
|
+
const port = Number(redirectUrl.port) || (redirectUrl.protocol === "https:" ? 443 : 80);
|
|
115
|
+
const callbackPath = redirectUrl.pathname;
|
|
116
|
+
const state = randomBytes(16).toString("hex");
|
|
117
|
+
const { tokens } = await new Promise((resolve, reject) => {
|
|
118
|
+
const timeout = setTimeout(() => {
|
|
119
|
+
server.close();
|
|
120
|
+
reject(new Error("Authorization timed out after 120 seconds."));
|
|
121
|
+
}, 12e4);
|
|
122
|
+
const server = createServer(async (req, res) => {
|
|
123
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
124
|
+
if (url.pathname !== callbackPath) {
|
|
125
|
+
res.writeHead(404);
|
|
126
|
+
res.end("Not found");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const returnedState = url.searchParams.get("state");
|
|
130
|
+
const code = url.searchParams.get("code");
|
|
131
|
+
const error = url.searchParams.get("error");
|
|
132
|
+
if (error) {
|
|
133
|
+
res.writeHead(400);
|
|
134
|
+
res.end(`Authorization error: ${error}`);
|
|
135
|
+
clearTimeout(timeout);
|
|
136
|
+
server.close();
|
|
137
|
+
reject(new Error(`Authorization denied: ${error}`));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (returnedState !== state) {
|
|
141
|
+
res.writeHead(400);
|
|
142
|
+
res.end("State mismatch \u2014 possible CSRF attack.");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!code) {
|
|
146
|
+
res.writeHead(400);
|
|
147
|
+
res.end("Missing authorization code.");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const exchangedTokens = await exchangeCode(code, redirectUri, clientId, clientSecret);
|
|
152
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
153
|
+
res.end("<h1>Authorization successful!</h1><p>You can close this tab.</p>");
|
|
154
|
+
clearTimeout(timeout);
|
|
155
|
+
server.close();
|
|
156
|
+
resolve({ tokens: exchangedTokens });
|
|
157
|
+
} catch (err) {
|
|
158
|
+
res.writeHead(500);
|
|
159
|
+
res.end("Token exchange failed.");
|
|
160
|
+
clearTimeout(timeout);
|
|
161
|
+
server.close();
|
|
162
|
+
reject(err);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
const params = {
|
|
166
|
+
response_type: "code",
|
|
167
|
+
client_id: clientId,
|
|
168
|
+
redirect_uri: redirectUri,
|
|
169
|
+
state
|
|
170
|
+
};
|
|
171
|
+
if (config.scope) {
|
|
172
|
+
params.scope = config.scope;
|
|
173
|
+
}
|
|
174
|
+
server.listen(port, "127.0.0.1", () => {
|
|
175
|
+
const authorizeUrl = `${config.authorizeUrl}?${new URLSearchParams(params)}`;
|
|
176
|
+
console.log("Opening browser for authorization...");
|
|
177
|
+
console.log(`If the browser doesn't open, visit:
|
|
178
|
+
${authorizeUrl}
|
|
179
|
+
`);
|
|
180
|
+
openBrowser(authorizeUrl);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
await writeTokens(tokens);
|
|
184
|
+
console.log(`Tokens saved to ${tokensPath}`);
|
|
185
|
+
}
|
|
186
|
+
async function refresh() {
|
|
187
|
+
const clientId = process.env[`${config.envPrefix}_CLIENT_ID`];
|
|
188
|
+
const clientSecret = process.env[`${config.envPrefix}_CLIENT_SECRET`];
|
|
189
|
+
if (!clientId)
|
|
190
|
+
throw new Error(`${config.envPrefix}_CLIENT_ID environment variable is required.`);
|
|
191
|
+
if (!clientSecret)
|
|
192
|
+
throw new Error(`${config.envPrefix}_CLIENT_SECRET environment variable is required.`);
|
|
193
|
+
const tokens = await readTokens();
|
|
194
|
+
if (!tokens || !isRefreshable(tokens)) {
|
|
195
|
+
throw new Error("No tokens found. Run 'yarn auth' first to authenticate.");
|
|
196
|
+
}
|
|
197
|
+
const refreshed = await refreshAccessToken(tokens.refresh_token, clientId, clientSecret);
|
|
198
|
+
await writeTokens(refreshed);
|
|
199
|
+
console.log(`Tokens refreshed and saved to ${tokensPath}`);
|
|
200
|
+
}
|
|
201
|
+
function runCli2(command) {
|
|
202
|
+
const cmd = command ?? process.argv[2];
|
|
203
|
+
let run;
|
|
204
|
+
if (cmd === "refresh" && config.supportsRefresh) {
|
|
205
|
+
run = refresh;
|
|
206
|
+
} else {
|
|
207
|
+
run = authorize;
|
|
208
|
+
}
|
|
209
|
+
run().catch((err) => {
|
|
210
|
+
console.error(err.message ?? err);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return { getAccessToken: getAccessToken2, runCli: runCli2 };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// src/auth.ts
|
|
218
|
+
var { getAccessToken, runCli } = createAuth({
|
|
219
|
+
name: "practicepanther-mcp",
|
|
220
|
+
authorizeUrl: "https://app.practicepanther.com/OAuth/Authorize",
|
|
221
|
+
tokenUrl: "https://app.practicepanther.com/OAuth/Token",
|
|
222
|
+
envPrefix: "PP",
|
|
223
|
+
scope: "full",
|
|
224
|
+
supportsRefresh: true
|
|
225
|
+
});
|
|
226
|
+
if (process.argv[1] && import.meta.filename === process.argv[1]) {
|
|
227
|
+
runCli();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export {
|
|
231
|
+
getAccessToken,
|
|
232
|
+
runCli
|
|
233
|
+
};
|
|
234
|
+
//# sourceMappingURL=chunk-A6QUURB5.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../shared/src/oauth.ts","../src/auth.ts"],"sourcesContent":["import { execSync } from \"node:child_process\";\nimport { randomBytes } from \"node:crypto\";\nimport { mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport {\n createServer,\n type IncomingMessage,\n type ServerResponse,\n} from \"node:http\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\n\nexport interface OAuthConfig {\n name: string;\n authorizeUrl: string;\n tokenUrl: string;\n envPrefix: string;\n scope?: string;\n supportsRefresh: boolean;\n}\n\ninterface BaseTokens {\n access_token: string;\n}\n\ninterface RefreshableTokens extends BaseTokens {\n refresh_token: string;\n expires_at: number;\n}\n\ntype StoredTokens = BaseTokens | RefreshableTokens;\n\nfunction isRefreshable(tokens: StoredTokens): tokens is RefreshableTokens {\n return \"refresh_token\" in tokens;\n}\n\ninterface RefreshableTokenResponse {\n access_token: string;\n refresh_token: string;\n expires_in: number;\n}\n\nexport function createAuth(config: OAuthConfig): {\n getAccessToken: () => Promise<string>;\n runCli: (command?: string) => void;\n} {\n const configDir = join(homedir(), \".config\", config.name);\n const tokensPath = join(configDir, \"tokens.json\");\n\n async function readTokens(): Promise<StoredTokens | null> {\n try {\n const data = await readFile(tokensPath, \"utf-8\");\n return JSON.parse(data) as StoredTokens;\n } catch {\n return null;\n }\n }\n\n async function writeTokens(tokens: StoredTokens): Promise<void> {\n await mkdir(configDir, { recursive: true });\n await writeFile(tokensPath, JSON.stringify(tokens, null, 2), {\n mode: 0o600,\n });\n }\n\n function openBrowser(url: string): void {\n const cmd =\n process.platform === \"darwin\"\n ? \"open\"\n : process.platform === \"win32\"\n ? \"start\"\n : \"xdg-open\";\n execSync(`${cmd} '${url}'`);\n }\n\n async function exchangeCode(\n code: string,\n redirectUri: string,\n clientId: string,\n clientSecret: string,\n ): Promise<StoredTokens> {\n const res = await fetch(config.tokenUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n grant_type: \"authorization_code\",\n code,\n redirect_uri: redirectUri,\n client_id: clientId,\n client_secret: clientSecret,\n }),\n });\n if (!res.ok) {\n const text = await res.text();\n throw new Error(`Token exchange failed (${res.status}): ${text}`);\n }\n if (config.supportsRefresh) {\n const data = (await res.json()) as RefreshableTokenResponse;\n return {\n access_token: data.access_token,\n refresh_token: data.refresh_token,\n expires_at: Date.now() + data.expires_in * 1000,\n };\n }\n const data = (await res.json()) as { access_token: string };\n return { access_token: data.access_token };\n }\n\n async function refreshAccessToken(\n refreshToken: string,\n clientId: string,\n clientSecret: string,\n ): Promise<RefreshableTokens> {\n const res = await fetch(config.tokenUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n grant_type: \"refresh_token\",\n refresh_token: refreshToken,\n client_id: clientId,\n client_secret: clientSecret,\n }),\n });\n if (!res.ok) {\n throw new Error(\"Run 'yarn auth' to re-authenticate.\");\n }\n const data = (await res.json()) as RefreshableTokenResponse;\n return {\n access_token: data.access_token,\n refresh_token: data.refresh_token,\n expires_at: Date.now() + data.expires_in * 1000,\n };\n }\n\n async function getAccessToken(): Promise<string> {\n const envToken = process.env[`${config.envPrefix}_ACCESS_TOKEN`];\n if (envToken) return envToken;\n\n const tokens = await readTokens();\n if (!tokens) {\n throw new Error(\n `No access token available. Set ${config.envPrefix}_ACCESS_TOKEN or run 'yarn auth'.`,\n );\n }\n\n if (config.supportsRefresh && isRefreshable(tokens)) {\n const fiveMinutes = 5 * 60 * 1000;\n if (tokens.expires_at - Date.now() < fiveMinutes) {\n const clientId = process.env[`${config.envPrefix}_CLIENT_ID`];\n const clientSecret = process.env[`${config.envPrefix}_CLIENT_SECRET`];\n if (!clientId || !clientSecret) {\n throw new Error(\n `Token is expiring and ${config.envPrefix}_CLIENT_ID/${config.envPrefix}_CLIENT_SECRET are not set for refresh. Run 'yarn auth'.`,\n );\n }\n const refreshed = await refreshAccessToken(\n tokens.refresh_token,\n clientId,\n clientSecret,\n );\n await writeTokens(refreshed);\n return refreshed.access_token;\n }\n }\n\n return tokens.access_token;\n }\n\n async function authorize(): Promise<void> {\n const clientId = process.env[`${config.envPrefix}_CLIENT_ID`];\n const clientSecret = process.env[`${config.envPrefix}_CLIENT_SECRET`];\n if (!clientId)\n throw new Error(\n `${config.envPrefix}_CLIENT_ID environment variable is required.`,\n );\n if (!clientSecret)\n throw new Error(\n `${config.envPrefix}_CLIENT_SECRET environment variable is required.`,\n );\n\n const redirectUri = process.env[`${config.envPrefix}_REDIRECT_URI`];\n if (!redirectUri)\n throw new Error(\n `${config.envPrefix}_REDIRECT_URI environment variable is required.`,\n );\n\n const redirectUrl = new URL(redirectUri);\n const port =\n Number(redirectUrl.port) ||\n (redirectUrl.protocol === \"https:\" ? 443 : 80);\n const callbackPath = redirectUrl.pathname;\n const state = randomBytes(16).toString(\"hex\");\n\n const { tokens } = await new Promise<{\n tokens: StoredTokens;\n }>((resolve, reject) => {\n const timeout = setTimeout(() => {\n server.close();\n reject(new Error(\"Authorization timed out after 120 seconds.\"));\n }, 120_000);\n\n const server = createServer(\n async (req: IncomingMessage, res: ServerResponse) => {\n const url = new URL(req.url ?? \"/\", `http://${req.headers.host}`);\n if (url.pathname !== callbackPath) {\n res.writeHead(404);\n res.end(\"Not found\");\n return;\n }\n\n const returnedState = url.searchParams.get(\"state\");\n const code = url.searchParams.get(\"code\");\n const error = url.searchParams.get(\"error\");\n\n if (error) {\n res.writeHead(400);\n res.end(`Authorization error: ${error}`);\n clearTimeout(timeout);\n server.close();\n reject(new Error(`Authorization denied: ${error}`));\n return;\n }\n\n if (returnedState !== state) {\n res.writeHead(400);\n res.end(\"State mismatch — possible CSRF attack.\");\n return;\n }\n\n if (!code) {\n res.writeHead(400);\n res.end(\"Missing authorization code.\");\n return;\n }\n\n try {\n const exchangedTokens = await exchangeCode(\n code,\n redirectUri,\n clientId,\n clientSecret,\n );\n res.writeHead(200, { \"Content-Type\": \"text/html\" });\n res.end(\n \"<h1>Authorization successful!</h1><p>You can close this tab.</p>\",\n );\n clearTimeout(timeout);\n server.close();\n resolve({ tokens: exchangedTokens });\n } catch (err) {\n res.writeHead(500);\n res.end(\"Token exchange failed.\");\n clearTimeout(timeout);\n server.close();\n reject(err);\n }\n },\n );\n\n const params: Record<string, string> = {\n response_type: \"code\",\n client_id: clientId,\n redirect_uri: redirectUri,\n state,\n };\n if (config.scope) {\n params.scope = config.scope;\n }\n\n server.listen(port, \"127.0.0.1\", () => {\n const authorizeUrl = `${config.authorizeUrl}?${new URLSearchParams(params)}`;\n\n console.log(\"Opening browser for authorization...\");\n console.log(`If the browser doesn't open, visit:\\n${authorizeUrl}\\n`);\n openBrowser(authorizeUrl);\n });\n });\n\n await writeTokens(tokens);\n console.log(`Tokens saved to ${tokensPath}`);\n }\n\n async function refresh(): Promise<void> {\n const clientId = process.env[`${config.envPrefix}_CLIENT_ID`];\n const clientSecret = process.env[`${config.envPrefix}_CLIENT_SECRET`];\n if (!clientId)\n throw new Error(\n `${config.envPrefix}_CLIENT_ID environment variable is required.`,\n );\n if (!clientSecret)\n throw new Error(\n `${config.envPrefix}_CLIENT_SECRET environment variable is required.`,\n );\n\n const tokens = await readTokens();\n if (!tokens || !isRefreshable(tokens)) {\n throw new Error(\n \"No tokens found. Run 'yarn auth' first to authenticate.\",\n );\n }\n\n const refreshed = await refreshAccessToken(\n tokens.refresh_token,\n clientId,\n clientSecret,\n );\n await writeTokens(refreshed);\n console.log(`Tokens refreshed and saved to ${tokensPath}`);\n }\n\n function runCli(command?: string): void {\n const cmd = command ?? process.argv[2];\n let run: () => Promise<void>;\n if (cmd === \"refresh\" && config.supportsRefresh) {\n run = refresh;\n } else {\n run = authorize;\n }\n run().catch((err) => {\n console.error(err.message ?? err);\n process.exit(1);\n });\n }\n\n return { getAccessToken, runCli };\n}\n","import { createAuth } from \"@mjquinlan2000/shared/oauth.js\";\n\nconst { getAccessToken, runCli } = createAuth({\n name: \"practicepanther-mcp\",\n authorizeUrl: \"https://app.practicepanther.com/OAuth/Authorize\",\n tokenUrl: \"https://app.practicepanther.com/OAuth/Token\",\n envPrefix: \"PP\",\n scope: \"full\",\n supportsRefresh: true,\n});\n\nexport { getAccessToken, runCli };\n\nif (process.argv[1] && import.meta.filename === process.argv[1]) {\n runCli();\n}\n"],"mappings":"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","names":["data","getAccessToken","runCli"]}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|