@phenomenalorg/mcp 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -0
- package/dist/auth.d.ts +79 -0
- package/dist/auth.js +274 -0
- package/dist/bridge.d.ts +16 -0
- package/dist/bridge.js +57 -0
- package/dist/cli.d.ts +22 -0
- package/dist/cli.js +143 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/store.d.ts +40 -0
- package/dist/store.js +87 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +12 -0
- package/package.json +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# @phenomenalorg/mcp
|
|
2
|
+
|
|
3
|
+
Connect an MCP client to **Phenomenal** — your school community's site, events,
|
|
4
|
+
volunteers, store, donations and communications — over stdio.
|
|
5
|
+
|
|
6
|
+
Phenomenal's MCP server is a normal remote MCP server at
|
|
7
|
+
`https://api.phenomenal.org/mcp`, and **most clients no longer need this
|
|
8
|
+
package**: they can speak Streamable HTTP directly and handle the browser
|
|
9
|
+
sign-in themselves. Reach for this bridge when your MCP client only speaks
|
|
10
|
+
stdio.
|
|
11
|
+
|
|
12
|
+
## Connect without the bridge (preferred)
|
|
13
|
+
|
|
14
|
+
**Claude Code**
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
claude mcp add --transport http phenomenal https://api.phenomenal.org/mcp
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then run `/mcp` in Claude Code and pick **phenomenal** to sign in.
|
|
21
|
+
|
|
22
|
+
**Codex CLI**
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
codex mcp add phenomenal --url https://api.phenomenal.org/mcp
|
|
26
|
+
codex mcp login phenomenal
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Claude Desktop**
|
|
30
|
+
|
|
31
|
+
Settings → **Connectors** → **Add custom connector** → paste
|
|
32
|
+
`https://api.phenomenal.org/mcp` → **Connect**, and approve the browser
|
|
33
|
+
consent screen.
|
|
34
|
+
|
|
35
|
+
## Connect with the bridge (stdio-only clients)
|
|
36
|
+
|
|
37
|
+
Add this to your client's MCP server config:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"mcpServers": {
|
|
42
|
+
"phenomenal": {
|
|
43
|
+
"command": "npx",
|
|
44
|
+
"args": ["-y", "@phenomenalorg/mcp"]
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The first launch opens your browser, you approve access to your school's
|
|
51
|
+
Phenomenal organization, and the bridge remembers the grant. There is no API
|
|
52
|
+
key to create, paste or rotate.
|
|
53
|
+
|
|
54
|
+
Point it somewhere else with a URL:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"mcpServers": {
|
|
59
|
+
"phenomenal": {
|
|
60
|
+
"command": "npx",
|
|
61
|
+
"args": ["-y", "@phenomenalorg/mcp", "--url", "https://api.ph-dev.org/mcp"]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Command line
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
npx @phenomenalorg/mcp [<url>] [--url <url>] [--logout] [--version] [--help]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
| Flag | What it does |
|
|
74
|
+
| ----------- | --------------------------------------------------------------------- |
|
|
75
|
+
| `--url` | The Phenomenal MCP endpoint. Default `https://api.phenomenal.org/mcp` |
|
|
76
|
+
| `--logout` | Forget the saved sign-in for that server and exit |
|
|
77
|
+
| `--version` | Print the version and exit |
|
|
78
|
+
| `--help` | Print usage and exit |
|
|
79
|
+
|
|
80
|
+
Sign out of one server:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npx @phenomenalorg/mcp --logout
|
|
84
|
+
npx @phenomenalorg/mcp --logout --url https://api.ph-dev.org/mcp
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Where your sign-in is kept
|
|
88
|
+
|
|
89
|
+
`~/.phenomenal/mcp/<server>.json`, one file per server, written `0600` inside a
|
|
90
|
+
`0700` directory. It holds the bridge's OAuth client registration and the
|
|
91
|
+
access/refresh tokens issued to it — treat it exactly like an SSH private key.
|
|
92
|
+
`--logout` deletes it; revoking the connection inside Phenomenal invalidates it
|
|
93
|
+
from the other end.
|
|
94
|
+
|
|
95
|
+
## How it works
|
|
96
|
+
|
|
97
|
+
- Sign-in is OAuth 2.1 with **PKCE** and **dynamic client registration**. The
|
|
98
|
+
bridge registers itself as the public client `Phenomenal MCP`, opens
|
|
99
|
+
your browser, and catches the redirect on a loopback listener bound to
|
|
100
|
+
`127.0.0.1` — never a routable interface.
|
|
101
|
+
- Once you have a token the bridge is a message pump: everything your client
|
|
102
|
+
writes on stdin goes to Phenomenal over Streamable HTTP and everything
|
|
103
|
+
Phenomenal sends back goes to stdout, notifications included.
|
|
104
|
+
- `stdout` is the protocol stream. Every diagnostic the bridge prints goes to
|
|
105
|
+
`stderr`, so if something looks wrong, that is where to look.
|
|
106
|
+
|
|
107
|
+
Requires Node 20 or newer.
|
|
108
|
+
|
|
109
|
+
## Support
|
|
110
|
+
|
|
111
|
+
Issues and questions: <https://github.com/PTO-pro/ptopro/issues>.
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
|
|
2
|
+
import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
|
|
3
|
+
/**
|
|
4
|
+
* What the consent screen shows the person approving this bridge, and what
|
|
5
|
+
* Settings → Connected agents lists it as. Customer-facing, so it is the
|
|
6
|
+
* product name — not an implementation label (Bob's call, #2523).
|
|
7
|
+
*/
|
|
8
|
+
export declare const CLIENT_NAME = "Phenomenal MCP";
|
|
9
|
+
export declare const CLIENT_URI = "https://phenomenal.org";
|
|
10
|
+
export interface LoopbackListener {
|
|
11
|
+
readonly port: number;
|
|
12
|
+
readonly redirectUrl: string;
|
|
13
|
+
/** Resolves with the authorization code once the browser comes back. */
|
|
14
|
+
waitForCallback(): Promise<{
|
|
15
|
+
code: string;
|
|
16
|
+
state?: string;
|
|
17
|
+
}>;
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A stable port per server, so the registered `redirect_uris` survive between
|
|
22
|
+
* runs — a fresh ephemeral port every time would invalidate the registration
|
|
23
|
+
* and force a new consent screen on every launch. Falls back to whatever the
|
|
24
|
+
* OS gives us when the preferred port is taken (and the registration is then
|
|
25
|
+
* re-done, see `PhenomenalOAuthProvider`).
|
|
26
|
+
*/
|
|
27
|
+
export declare function preferredLoopbackPort(serverUrl: string): number;
|
|
28
|
+
export declare function loopbackRedirectUrl(port: number): string;
|
|
29
|
+
/**
|
|
30
|
+
* Binds 127.0.0.1 — never 0.0.0.0. The authorization code arrives in this URL,
|
|
31
|
+
* and a listener on a routable interface would hand it to the local network.
|
|
32
|
+
*/
|
|
33
|
+
export declare function startLoopback(preferredPort?: number): Promise<LoopbackListener>;
|
|
34
|
+
export interface ProviderOptions {
|
|
35
|
+
serverUrl: string;
|
|
36
|
+
redirectUrl: string;
|
|
37
|
+
home?: string;
|
|
38
|
+
openBrowser?: (url: URL) => Promise<unknown>;
|
|
39
|
+
log?: (line: string) => void;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The SDK's `OAuthClientProvider`, backed by `~/.phenomenal/mcp/<host>.json`.
|
|
43
|
+
*/
|
|
44
|
+
export declare class PhenomenalOAuthProvider implements OAuthClientProvider {
|
|
45
|
+
private readonly serverUrl;
|
|
46
|
+
private readonly home;
|
|
47
|
+
private readonly redirect;
|
|
48
|
+
private readonly openBrowser;
|
|
49
|
+
private readonly log;
|
|
50
|
+
private issuedState;
|
|
51
|
+
constructor(options: ProviderOptions);
|
|
52
|
+
private session;
|
|
53
|
+
get redirectUrl(): string;
|
|
54
|
+
get clientMetadata(): OAuthClientMetadata;
|
|
55
|
+
/** The state we last handed the authorization server, for the callback check. */
|
|
56
|
+
get lastIssuedState(): string | undefined;
|
|
57
|
+
state(): string;
|
|
58
|
+
clientInformation(): OAuthClientInformationMixed | undefined;
|
|
59
|
+
saveClientInformation(clientInformation: OAuthClientInformationMixed): void;
|
|
60
|
+
tokens(): OAuthTokens | undefined;
|
|
61
|
+
saveTokens(tokens: OAuthTokens): void;
|
|
62
|
+
redirectToAuthorization(authorizationUrl: URL): Promise<void>;
|
|
63
|
+
saveCodeVerifier(codeVerifier: string): void;
|
|
64
|
+
codeVerifier(): string;
|
|
65
|
+
invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void;
|
|
66
|
+
}
|
|
67
|
+
export interface EnsureAuthorizedOptions {
|
|
68
|
+
home?: string;
|
|
69
|
+
openBrowser?: (url: URL) => Promise<unknown>;
|
|
70
|
+
log?: (line: string) => void;
|
|
71
|
+
now?: () => number;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Returns a provider holding a usable access token, opening a browser only if
|
|
75
|
+
* it has to. The loopback port is bound for the length of the sign-in and
|
|
76
|
+
* closed before the pump starts — a bridge that idles with an open listener is
|
|
77
|
+
* a bridge with an open listener all day.
|
|
78
|
+
*/
|
|
79
|
+
export declare function ensureAuthorized(serverUrl: string, options?: EnsureAuthorizedOptions): Promise<PhenomenalOAuthProvider>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Browser sign-in for the bridge: dynamic client registration, PKCE, and a
|
|
2
|
+
// loopback listener on 127.0.0.1 that catches the redirect. There is no key to
|
|
3
|
+
// paste and nothing for a customer to configure — they click "Allow" in
|
|
4
|
+
// Phenomenal and the bridge holds the grant from then on.
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import open from 'open';
|
|
8
|
+
import { auth } from '@modelcontextprotocol/sdk/client/auth.js';
|
|
9
|
+
import { expiryFromTokens, readSession, tokensAreFresh, updateSession, } from './store.js';
|
|
10
|
+
/**
|
|
11
|
+
* What the consent screen shows the person approving this bridge, and what
|
|
12
|
+
* Settings → Connected agents lists it as. Customer-facing, so it is the
|
|
13
|
+
* product name — not an implementation label (Bob's call, #2523).
|
|
14
|
+
*/
|
|
15
|
+
export const CLIENT_NAME = 'Phenomenal MCP';
|
|
16
|
+
export const CLIENT_URI = 'https://phenomenal.org';
|
|
17
|
+
/**
|
|
18
|
+
* A stable port per server, so the registered `redirect_uris` survive between
|
|
19
|
+
* runs — a fresh ephemeral port every time would invalidate the registration
|
|
20
|
+
* and force a new consent screen on every launch. Falls back to whatever the
|
|
21
|
+
* OS gives us when the preferred port is taken (and the registration is then
|
|
22
|
+
* re-done, see `PhenomenalOAuthProvider`).
|
|
23
|
+
*/
|
|
24
|
+
export function preferredLoopbackPort(serverUrl) {
|
|
25
|
+
let hash = 2166136261;
|
|
26
|
+
for (const ch of new URL(serverUrl).origin) {
|
|
27
|
+
hash ^= ch.charCodeAt(0);
|
|
28
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
29
|
+
}
|
|
30
|
+
return 33200 + (hash % 100);
|
|
31
|
+
}
|
|
32
|
+
export function loopbackRedirectUrl(port) {
|
|
33
|
+
return `http://127.0.0.1:${port}/callback`;
|
|
34
|
+
}
|
|
35
|
+
const DONE_PAGE = (heading, detail) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Phenomenal</title>` +
|
|
36
|
+
`<style>body{font-family:ui-sans-serif,system-ui,sans-serif;background:#0b0b12;color:#fff;` +
|
|
37
|
+
`display:grid;place-items:center;height:100vh;margin:0}main{text-align:center;max-width:32rem}` +
|
|
38
|
+
`h1{color:#b6f441;font-size:1.5rem}p{color:#cfcfd8}</style></head>` +
|
|
39
|
+
`<body><main><h1>${heading}</h1><p>${detail}</p></main></body></html>`;
|
|
40
|
+
/**
|
|
41
|
+
* Binds 127.0.0.1 — never 0.0.0.0. The authorization code arrives in this URL,
|
|
42
|
+
* and a listener on a routable interface would hand it to the local network.
|
|
43
|
+
*/
|
|
44
|
+
export async function startLoopback(preferredPort = 0) {
|
|
45
|
+
let settle;
|
|
46
|
+
let fail;
|
|
47
|
+
let received;
|
|
48
|
+
let failure;
|
|
49
|
+
const server = createServer((req, res) => {
|
|
50
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
51
|
+
if (url.pathname !== '/callback') {
|
|
52
|
+
res.writeHead(404, { 'content-type': 'text/plain' }).end('Not found');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const error = url.searchParams.get('error');
|
|
56
|
+
if (error) {
|
|
57
|
+
const description = url.searchParams.get('error_description') ?? error;
|
|
58
|
+
res
|
|
59
|
+
.writeHead(400, { 'content-type': 'text/html; charset=utf-8' })
|
|
60
|
+
.end(DONE_PAGE('Sign-in was not completed', escapeHtml(description)));
|
|
61
|
+
const err = new Error(`Phenomenal sign-in failed: ${description}`);
|
|
62
|
+
failure = err;
|
|
63
|
+
fail?.(err);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const code = url.searchParams.get('code');
|
|
67
|
+
if (!code) {
|
|
68
|
+
res.writeHead(400, { 'content-type': 'text/plain' }).end('Missing code');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
res
|
|
72
|
+
.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
73
|
+
.end(DONE_PAGE('You are connected to Phenomenal', 'You can close this tab and go back to your assistant.'));
|
|
74
|
+
received = { code, state: url.searchParams.get('state') ?? undefined };
|
|
75
|
+
settle?.(received);
|
|
76
|
+
});
|
|
77
|
+
await new Promise((resolve, reject) => {
|
|
78
|
+
const onError = (error) => {
|
|
79
|
+
if (preferredPort !== 0 && (error.code === 'EADDRINUSE' || error.code === 'EACCES')) {
|
|
80
|
+
// Something else holds our stable port; take any port and re-register.
|
|
81
|
+
server.listen(0, '127.0.0.1');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
reject(error);
|
|
85
|
+
};
|
|
86
|
+
server.on('error', onError);
|
|
87
|
+
server.once('listening', () => {
|
|
88
|
+
server.off('error', onError);
|
|
89
|
+
server.on('error', () => { });
|
|
90
|
+
resolve();
|
|
91
|
+
});
|
|
92
|
+
server.listen(preferredPort, '127.0.0.1');
|
|
93
|
+
});
|
|
94
|
+
const port = server.address().port;
|
|
95
|
+
return {
|
|
96
|
+
port,
|
|
97
|
+
redirectUrl: loopbackRedirectUrl(port),
|
|
98
|
+
waitForCallback: () => new Promise((resolve, reject) => {
|
|
99
|
+
if (received)
|
|
100
|
+
return resolve(received);
|
|
101
|
+
if (failure)
|
|
102
|
+
return reject(failure);
|
|
103
|
+
settle = resolve;
|
|
104
|
+
fail = reject;
|
|
105
|
+
}),
|
|
106
|
+
close: () => new Promise((resolve) => {
|
|
107
|
+
server.closeAllConnections();
|
|
108
|
+
server.close(() => resolve());
|
|
109
|
+
}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The SDK's `OAuthClientProvider`, backed by `~/.phenomenal/mcp/<host>.json`.
|
|
114
|
+
*/
|
|
115
|
+
export class PhenomenalOAuthProvider {
|
|
116
|
+
serverUrl;
|
|
117
|
+
home;
|
|
118
|
+
redirect;
|
|
119
|
+
openBrowser;
|
|
120
|
+
log;
|
|
121
|
+
issuedState;
|
|
122
|
+
constructor(options) {
|
|
123
|
+
this.serverUrl = options.serverUrl;
|
|
124
|
+
this.home = options.home;
|
|
125
|
+
this.redirect = options.redirectUrl;
|
|
126
|
+
this.openBrowser = options.openBrowser ?? ((url) => open(url.toString()));
|
|
127
|
+
this.log = options.log ?? (() => { });
|
|
128
|
+
// A registration issued for a different loopback port cannot be used: the
|
|
129
|
+
// authorization request's redirect_uri would not match what was
|
|
130
|
+
// registered. Drop it and let the SDK register again.
|
|
131
|
+
const stored = readSession(this.serverUrl, this.home);
|
|
132
|
+
if (stored?.clientInformation &&
|
|
133
|
+
!stored.clientInformation.redirect_uris.includes(this.redirect)) {
|
|
134
|
+
updateSession(this.serverUrl, { clientInformation: undefined, codeVerifier: undefined, redirectPort: undefined }, this.home);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
session() {
|
|
138
|
+
return readSession(this.serverUrl, this.home) ?? { serverUrl: this.serverUrl };
|
|
139
|
+
}
|
|
140
|
+
get redirectUrl() {
|
|
141
|
+
return this.redirect;
|
|
142
|
+
}
|
|
143
|
+
get clientMetadata() {
|
|
144
|
+
return {
|
|
145
|
+
client_name: CLIENT_NAME,
|
|
146
|
+
client_uri: CLIENT_URI,
|
|
147
|
+
redirect_uris: [this.redirect],
|
|
148
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
149
|
+
response_types: ['code'],
|
|
150
|
+
// A CLI on a customer's laptop cannot keep a secret, so it registers as
|
|
151
|
+
// a public client and leans on PKCE.
|
|
152
|
+
token_endpoint_auth_method: 'none',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/** The state we last handed the authorization server, for the callback check. */
|
|
156
|
+
get lastIssuedState() {
|
|
157
|
+
return this.issuedState;
|
|
158
|
+
}
|
|
159
|
+
state() {
|
|
160
|
+
this.issuedState = randomBytes(16).toString('hex');
|
|
161
|
+
return this.issuedState;
|
|
162
|
+
}
|
|
163
|
+
clientInformation() {
|
|
164
|
+
return this.session().clientInformation;
|
|
165
|
+
}
|
|
166
|
+
saveClientInformation(clientInformation) {
|
|
167
|
+
const full = clientInformation;
|
|
168
|
+
updateSession(this.serverUrl, { clientInformation: full, redirectPort: portOf(this.redirect) }, this.home);
|
|
169
|
+
}
|
|
170
|
+
tokens() {
|
|
171
|
+
return this.session().tokens;
|
|
172
|
+
}
|
|
173
|
+
saveTokens(tokens) {
|
|
174
|
+
updateSession(this.serverUrl,
|
|
175
|
+
// The verifier is single-use; keeping it around only widens the window
|
|
176
|
+
// in which a stolen store file is worth something.
|
|
177
|
+
{ tokens, expiresAt: expiryFromTokens(tokens), codeVerifier: undefined }, this.home);
|
|
178
|
+
}
|
|
179
|
+
async redirectToAuthorization(authorizationUrl) {
|
|
180
|
+
this.log(`Opening your browser to sign in to Phenomenal…`);
|
|
181
|
+
this.log(`If it does not open, visit: ${authorizationUrl.toString()}`);
|
|
182
|
+
try {
|
|
183
|
+
await this.openBrowser(authorizationUrl);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
this.log(`Could not open a browser automatically (${describe(error)}). Use the link above.`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
saveCodeVerifier(codeVerifier) {
|
|
190
|
+
updateSession(this.serverUrl, { codeVerifier }, this.home);
|
|
191
|
+
}
|
|
192
|
+
codeVerifier() {
|
|
193
|
+
const verifier = this.session().codeVerifier;
|
|
194
|
+
if (!verifier) {
|
|
195
|
+
throw new Error('No PKCE code verifier is saved — start the sign-in again.');
|
|
196
|
+
}
|
|
197
|
+
return verifier;
|
|
198
|
+
}
|
|
199
|
+
invalidateCredentials(scope) {
|
|
200
|
+
const patch = scope === 'all'
|
|
201
|
+
? {
|
|
202
|
+
clientInformation: undefined,
|
|
203
|
+
tokens: undefined,
|
|
204
|
+
codeVerifier: undefined,
|
|
205
|
+
expiresAt: undefined,
|
|
206
|
+
}
|
|
207
|
+
: scope === 'client'
|
|
208
|
+
? { clientInformation: undefined }
|
|
209
|
+
: scope === 'tokens'
|
|
210
|
+
? { tokens: undefined, expiresAt: undefined }
|
|
211
|
+
: scope === 'verifier'
|
|
212
|
+
? { codeVerifier: undefined }
|
|
213
|
+
: {};
|
|
214
|
+
updateSession(this.serverUrl, patch, this.home);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Returns a provider holding a usable access token, opening a browser only if
|
|
219
|
+
* it has to. The loopback port is bound for the length of the sign-in and
|
|
220
|
+
* closed before the pump starts — a bridge that idles with an open listener is
|
|
221
|
+
* a bridge with an open listener all day.
|
|
222
|
+
*/
|
|
223
|
+
export async function ensureAuthorized(serverUrl, options = {}) {
|
|
224
|
+
const { home, openBrowser, log = () => { } } = options;
|
|
225
|
+
const stored = readSession(serverUrl, home);
|
|
226
|
+
if (tokensAreFresh(stored, options.now?.() ?? Date.now())) {
|
|
227
|
+
return new PhenomenalOAuthProvider({
|
|
228
|
+
serverUrl,
|
|
229
|
+
// Reuse the registration's own redirect so a still-valid registration is
|
|
230
|
+
// not thrown away just because we did not bind a port this run.
|
|
231
|
+
redirectUrl: stored?.clientInformation?.redirect_uris[0] ??
|
|
232
|
+
loopbackRedirectUrl(stored?.redirectPort ?? preferredLoopbackPort(serverUrl)),
|
|
233
|
+
home,
|
|
234
|
+
openBrowser,
|
|
235
|
+
log,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
const loopback = await startLoopback(stored?.redirectPort ?? preferredLoopbackPort(serverUrl));
|
|
239
|
+
try {
|
|
240
|
+
const provider = new PhenomenalOAuthProvider({
|
|
241
|
+
serverUrl,
|
|
242
|
+
redirectUrl: loopback.redirectUrl,
|
|
243
|
+
home,
|
|
244
|
+
openBrowser,
|
|
245
|
+
log,
|
|
246
|
+
});
|
|
247
|
+
const result = await auth(provider, { serverUrl });
|
|
248
|
+
if (result === 'AUTHORIZED')
|
|
249
|
+
return provider;
|
|
250
|
+
const callback = await loopback.waitForCallback();
|
|
251
|
+
if (provider.lastIssuedState && callback.state !== provider.lastIssuedState) {
|
|
252
|
+
throw new Error('The sign-in response did not match the request that started it.');
|
|
253
|
+
}
|
|
254
|
+
const finished = await auth(provider, { serverUrl, authorizationCode: callback.code });
|
|
255
|
+
if (finished !== 'AUTHORIZED') {
|
|
256
|
+
throw new Error('Phenomenal sign-in did not complete.');
|
|
257
|
+
}
|
|
258
|
+
log('Signed in to Phenomenal.');
|
|
259
|
+
return provider;
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
await loopback.close();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function portOf(redirectUrl) {
|
|
266
|
+
const port = Number(new URL(redirectUrl).port);
|
|
267
|
+
return Number.isFinite(port) && port > 0 ? port : undefined;
|
|
268
|
+
}
|
|
269
|
+
function escapeHtml(value) {
|
|
270
|
+
return value.replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]);
|
|
271
|
+
}
|
|
272
|
+
function describe(error) {
|
|
273
|
+
return error instanceof Error ? error.message : String(error);
|
|
274
|
+
}
|
package/dist/bridge.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
2
|
+
export interface BridgeOptions {
|
|
3
|
+
/** Where diagnostics go. stdout is the protocol, so this must not be it. */
|
|
4
|
+
log?: (line: string) => void;
|
|
5
|
+
}
|
|
6
|
+
export interface BridgeHandle {
|
|
7
|
+
/** Resolves once both transports are closed. */
|
|
8
|
+
readonly done: Promise<void>;
|
|
9
|
+
close(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Wires two transports together and starts them. The remote starts first: the
|
|
13
|
+
* local (stdio) transport begins reading stdin the moment it starts, and a
|
|
14
|
+
* message read before the remote is up would have nowhere to go.
|
|
15
|
+
*/
|
|
16
|
+
export declare function startBridge(local: Transport, remote: Transport, options?: BridgeOptions): Promise<BridgeHandle>;
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one thing a raw pump has to do that a `Client` would have done for it:
|
|
3
|
+
* the Protocol class calls `setProtocolVersion` when the initialize result
|
|
4
|
+
* arrives, and the Streamable HTTP transport sends that value as the
|
|
5
|
+
* `MCP-Protocol-Version` header on every later request. With nobody in the
|
|
6
|
+
* middle, the pump has to read it off the wire.
|
|
7
|
+
*/
|
|
8
|
+
function negotiatedProtocolVersion(message) {
|
|
9
|
+
if (!('result' in message) || typeof message.result !== 'object' || message.result === null) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
const version = message.result.protocolVersion;
|
|
13
|
+
return typeof version === 'string' ? version : undefined;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Wires two transports together and starts them. The remote starts first: the
|
|
17
|
+
* local (stdio) transport begins reading stdin the moment it starts, and a
|
|
18
|
+
* message read before the remote is up would have nowhere to go.
|
|
19
|
+
*/
|
|
20
|
+
export async function startBridge(local, remote, options = {}) {
|
|
21
|
+
const log = options.log ?? (() => { });
|
|
22
|
+
let shuttingDown = false;
|
|
23
|
+
let resolveDone;
|
|
24
|
+
const done = new Promise((resolve) => {
|
|
25
|
+
resolveDone = resolve;
|
|
26
|
+
});
|
|
27
|
+
const shutdown = () => {
|
|
28
|
+
if (shuttingDown)
|
|
29
|
+
return;
|
|
30
|
+
shuttingDown = true;
|
|
31
|
+
void Promise.allSettled([local.close(), remote.close()]).then(() => resolveDone());
|
|
32
|
+
};
|
|
33
|
+
const forward = (to, label) => (message) => {
|
|
34
|
+
to.send(message).catch((error) => {
|
|
35
|
+
log(`failed to forward a message ${label}: ${describe(error)}`);
|
|
36
|
+
shutdown();
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
local.onmessage = forward(remote, 'to the Phenomenal server');
|
|
40
|
+
remote.onmessage = (message) => {
|
|
41
|
+
const version = negotiatedProtocolVersion(message);
|
|
42
|
+
if (version)
|
|
43
|
+
remote.setProtocolVersion?.(version);
|
|
44
|
+
forward(local, 'to the MCP client')(message);
|
|
45
|
+
};
|
|
46
|
+
// An error is not necessarily fatal (the SDK says so explicitly); a close is.
|
|
47
|
+
local.onerror = (error) => log(`MCP client transport error: ${describe(error)}`);
|
|
48
|
+
remote.onerror = (error) => log(`Phenomenal server transport error: ${describe(error)}`);
|
|
49
|
+
local.onclose = shutdown;
|
|
50
|
+
remote.onclose = shutdown;
|
|
51
|
+
await remote.start();
|
|
52
|
+
await local.start();
|
|
53
|
+
return { done, close: async () => (shutdown(), done) };
|
|
54
|
+
}
|
|
55
|
+
function describe(error) {
|
|
56
|
+
return error instanceof Error ? error.message : String(error);
|
|
57
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export declare const DEFAULT_SERVER_URL = "https://api.phenomenal.org/mcp";
|
|
3
|
+
export interface CliOptions {
|
|
4
|
+
url: string;
|
|
5
|
+
logout: boolean;
|
|
6
|
+
version: boolean;
|
|
7
|
+
help: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare class CliUsageError extends Error {
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* `--url <u>`, `--url=<u>` or a bare positional URL; `--logout`, `--version`,
|
|
13
|
+
* `--help`. Anything else is a usage error rather than a silently ignored
|
|
14
|
+
* flag — an MCP client config typo should say so, not connect somewhere else.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseArgs(argv: readonly string[]): CliOptions;
|
|
17
|
+
export declare function usage(version: string): string;
|
|
18
|
+
export interface CliIo {
|
|
19
|
+
out: (line: string) => void;
|
|
20
|
+
err: (line: string) => void;
|
|
21
|
+
}
|
|
22
|
+
export declare function run(argv: readonly string[], io?: CliIo): Promise<number>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// phenomenal-mcp — the stdio bridge to a Phenomenal MCP server.
|
|
3
|
+
//
|
|
4
|
+
// stdout is the MCP protocol stream. NOTHING may be written there except
|
|
5
|
+
// JSON-RPC: a stray log line corrupts the session and the client reports a
|
|
6
|
+
// parse error it cannot attribute. Diagnostics go to stderr.
|
|
7
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
9
|
+
import { ensureAuthorized } from './auth.js';
|
|
10
|
+
import { startBridge } from './bridge.js';
|
|
11
|
+
import { clearSession, storePath } from './store.js';
|
|
12
|
+
import { readPackageVersion } from './version.js';
|
|
13
|
+
export const DEFAULT_SERVER_URL = 'https://api.phenomenal.org/mcp';
|
|
14
|
+
export class CliUsageError extends Error {
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* `--url <u>`, `--url=<u>` or a bare positional URL; `--logout`, `--version`,
|
|
18
|
+
* `--help`. Anything else is a usage error rather than a silently ignored
|
|
19
|
+
* flag — an MCP client config typo should say so, not connect somewhere else.
|
|
20
|
+
*/
|
|
21
|
+
export function parseArgs(argv) {
|
|
22
|
+
const options = {
|
|
23
|
+
url: DEFAULT_SERVER_URL,
|
|
24
|
+
logout: false,
|
|
25
|
+
version: false,
|
|
26
|
+
help: false,
|
|
27
|
+
};
|
|
28
|
+
let urlSeen = false;
|
|
29
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
30
|
+
const arg = argv[i];
|
|
31
|
+
if (arg === '--logout') {
|
|
32
|
+
options.logout = true;
|
|
33
|
+
}
|
|
34
|
+
else if (arg === '--version' || arg === '-v') {
|
|
35
|
+
options.version = true;
|
|
36
|
+
}
|
|
37
|
+
else if (arg === '--help' || arg === '-h') {
|
|
38
|
+
options.help = true;
|
|
39
|
+
}
|
|
40
|
+
else if (arg === '--url') {
|
|
41
|
+
const value = argv[i + 1];
|
|
42
|
+
if (!value || value.startsWith('-'))
|
|
43
|
+
throw new CliUsageError('--url needs a URL');
|
|
44
|
+
options.url = validUrl(value);
|
|
45
|
+
urlSeen = true;
|
|
46
|
+
i += 1;
|
|
47
|
+
}
|
|
48
|
+
else if (arg.startsWith('--url=')) {
|
|
49
|
+
options.url = validUrl(arg.slice('--url='.length));
|
|
50
|
+
urlSeen = true;
|
|
51
|
+
}
|
|
52
|
+
else if (!arg.startsWith('-') && !urlSeen) {
|
|
53
|
+
options.url = validUrl(arg);
|
|
54
|
+
urlSeen = true;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
throw new CliUsageError(`unknown argument: ${arg}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return options;
|
|
61
|
+
}
|
|
62
|
+
function validUrl(value) {
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = new URL(value);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
throw new CliUsageError(`not a URL: ${value}`);
|
|
69
|
+
}
|
|
70
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
71
|
+
throw new CliUsageError(`the server URL must be http(s): ${value}`);
|
|
72
|
+
}
|
|
73
|
+
return parsed.toString();
|
|
74
|
+
}
|
|
75
|
+
export function usage(version) {
|
|
76
|
+
return [
|
|
77
|
+
`phenomenal-mcp ${version} — connect an MCP client to Phenomenal over stdio.`,
|
|
78
|
+
'',
|
|
79
|
+
'Usage:',
|
|
80
|
+
' npx @phenomenalorg/mcp [<url>] [--url <url>] [--logout] [--version]',
|
|
81
|
+
'',
|
|
82
|
+
'Options:',
|
|
83
|
+
` --url <url> The Phenomenal MCP endpoint (default ${DEFAULT_SERVER_URL})`,
|
|
84
|
+
' --logout Forget the saved sign-in for that server and exit',
|
|
85
|
+
' --version Print the version and exit',
|
|
86
|
+
' --help Print this and exit',
|
|
87
|
+
'',
|
|
88
|
+
'Most clients no longer need this bridge — Claude Code, Codex and Claude',
|
|
89
|
+
'Desktop can talk to Phenomenal directly over HTTP. Use it when your client',
|
|
90
|
+
'only speaks stdio.',
|
|
91
|
+
].join('\n');
|
|
92
|
+
}
|
|
93
|
+
const defaultIo = {
|
|
94
|
+
out: (line) => process.stdout.write(`${line}\n`),
|
|
95
|
+
err: (line) => process.stderr.write(`phenomenal-mcp: ${line}\n`),
|
|
96
|
+
};
|
|
97
|
+
export async function run(argv, io = defaultIo) {
|
|
98
|
+
const version = readPackageVersion();
|
|
99
|
+
let options;
|
|
100
|
+
try {
|
|
101
|
+
options = parseArgs(argv);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
io.err(error instanceof CliUsageError ? error.message : String(error));
|
|
105
|
+
io.err('Try --help.');
|
|
106
|
+
return 2;
|
|
107
|
+
}
|
|
108
|
+
if (options.help) {
|
|
109
|
+
io.out(usage(version));
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
if (options.version) {
|
|
113
|
+
io.out(version);
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
if (options.logout) {
|
|
117
|
+
const had = clearSession(options.url);
|
|
118
|
+
io.err(had
|
|
119
|
+
? `Signed out of ${options.url}. Removed ${storePath(options.url)}.`
|
|
120
|
+
: `No saved sign-in for ${options.url}.`);
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
const provider = await ensureAuthorized(options.url, { log: io.err });
|
|
124
|
+
const remote = new StreamableHTTPClientTransport(new URL(options.url), {
|
|
125
|
+
authProvider: provider,
|
|
126
|
+
});
|
|
127
|
+
const local = new StdioServerTransport();
|
|
128
|
+
io.err(`Connected to ${options.url}.`);
|
|
129
|
+
const handle = await startBridge(local, remote, { log: io.err });
|
|
130
|
+
await handle.done;
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
// `import.meta.url` is the built dist/cli.js when npm runs the bin; the guard
|
|
134
|
+
// keeps `run` importable from tests without starting a session.
|
|
135
|
+
const invokedDirectly = process.argv[1] !== undefined && import.meta.url.endsWith('/cli.js');
|
|
136
|
+
if (invokedDirectly) {
|
|
137
|
+
run(process.argv.slice(2)).then((code) => {
|
|
138
|
+
process.exitCode = code;
|
|
139
|
+
}, (error) => {
|
|
140
|
+
process.stderr.write(`phenomenal-mcp: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
});
|
|
143
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { startBridge, type BridgeHandle, type BridgeOptions } from './bridge.js';
|
|
2
|
+
export { CLIENT_NAME, CLIENT_URI, ensureAuthorized, loopbackRedirectUrl, PhenomenalOAuthProvider, preferredLoopbackPort, startLoopback, type LoopbackListener, } from './auth.js';
|
|
3
|
+
export { clearSession, readSession, storePath, storeRoot, tokensAreFresh, type StoredSession, } from './store.js';
|
|
4
|
+
export { DEFAULT_SERVER_URL, parseArgs, run, usage, type CliOptions } from './cli.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { startBridge } from './bridge.js';
|
|
2
|
+
export { CLIENT_NAME, CLIENT_URI, ensureAuthorized, loopbackRedirectUrl, PhenomenalOAuthProvider, preferredLoopbackPort, startLoopback, } from './auth.js';
|
|
3
|
+
export { clearSession, readSession, storePath, storeRoot, tokensAreFresh, } from './store.js';
|
|
4
|
+
export { DEFAULT_SERVER_URL, parseArgs, run, usage } from './cli.js';
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
|
|
2
|
+
/** Everything the bridge remembers about one Phenomenal MCP server. */
|
|
3
|
+
export interface StoredSession {
|
|
4
|
+
/** The `/mcp` URL these credentials belong to. */
|
|
5
|
+
serverUrl: string;
|
|
6
|
+
/** The loopback port the registration's `redirect_uris` were issued for. */
|
|
7
|
+
redirectPort?: number;
|
|
8
|
+
clientInformation?: OAuthClientInformationFull;
|
|
9
|
+
tokens?: OAuthTokens;
|
|
10
|
+
/** In-flight PKCE verifier; only meaningful between redirect and callback. */
|
|
11
|
+
codeVerifier?: string;
|
|
12
|
+
/** Epoch ms at which `tokens.access_token` expires, if the server said. */
|
|
13
|
+
expiresAt?: number;
|
|
14
|
+
}
|
|
15
|
+
export declare const STORE_DIR_MODE = 448;
|
|
16
|
+
export declare const STORE_FILE_MODE = 384;
|
|
17
|
+
/** Refresh this long before the declared expiry rather than racing it. */
|
|
18
|
+
export declare const EXPIRY_SKEW_MS = 60000;
|
|
19
|
+
export declare function storeRoot(home?: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* One file per server. Host and path both matter (two servers can share a
|
|
22
|
+
* host), and every character outside a conservative allowlist is replaced —
|
|
23
|
+
* so no input can walk out of the store directory.
|
|
24
|
+
*/
|
|
25
|
+
export declare function storeFileName(serverUrl: string): string;
|
|
26
|
+
export declare function storePath(serverUrl: string, home?: string): string;
|
|
27
|
+
export declare function readSession(serverUrl: string, home?: string): StoredSession | undefined;
|
|
28
|
+
export declare function writeSession(serverUrl: string, session: StoredSession, home?: string): void;
|
|
29
|
+
/** Merge a patch into the stored session. Returns the session as written. */
|
|
30
|
+
export declare function updateSession(serverUrl: string, patch: Partial<StoredSession>, home?: string): StoredSession;
|
|
31
|
+
/** `--logout`. Returns whether there was anything to delete. */
|
|
32
|
+
export declare function clearSession(serverUrl: string, home?: string): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Whether the stored access token can be used without talking to the
|
|
35
|
+
* authorization server first. An absent `expiresAt` means the server declared
|
|
36
|
+
* no expiry — the token is good until it is refused.
|
|
37
|
+
*/
|
|
38
|
+
export declare function tokensAreFresh(session: StoredSession | undefined, now?: number): boolean;
|
|
39
|
+
/** Epoch ms for a token response's `expires_in`, or undefined if it had none. */
|
|
40
|
+
export declare function expiryFromTokens(tokens: OAuthTokens, now?: number): number | undefined;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The on-disk half of the bridge: one file per Phenomenal server, holding the
|
|
2
|
+
// dynamic client registration and the OAuth tokens obtained for it.
|
|
3
|
+
//
|
|
4
|
+
// These are a person's credentials for their school's data, sitting in a
|
|
5
|
+
// customer's home directory, so the file is written 0600 and its directory
|
|
6
|
+
// 0700 — and both are re-tightened on every write, because `writeFileSync`'s
|
|
7
|
+
// `mode` applies only when it CREATES the file. A file that was once written
|
|
8
|
+
// world-readable stays world-readable otherwise.
|
|
9
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
export const STORE_DIR_MODE = 0o700;
|
|
13
|
+
export const STORE_FILE_MODE = 0o600;
|
|
14
|
+
/** Refresh this long before the declared expiry rather than racing it. */
|
|
15
|
+
export const EXPIRY_SKEW_MS = 60_000;
|
|
16
|
+
export function storeRoot(home = homedir()) {
|
|
17
|
+
return join(home, '.phenomenal', 'mcp');
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* One file per server. Host and path both matter (two servers can share a
|
|
21
|
+
* host), and every character outside a conservative allowlist is replaced —
|
|
22
|
+
* so no input can walk out of the store directory.
|
|
23
|
+
*/
|
|
24
|
+
export function storeFileName(serverUrl) {
|
|
25
|
+
const url = new URL(serverUrl);
|
|
26
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
27
|
+
const key = `${url.host}${path}`.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
28
|
+
return `${key}.json`;
|
|
29
|
+
}
|
|
30
|
+
export function storePath(serverUrl, home = homedir()) {
|
|
31
|
+
return join(storeRoot(home), storeFileName(serverUrl));
|
|
32
|
+
}
|
|
33
|
+
export function readSession(serverUrl, home) {
|
|
34
|
+
const path = storePath(serverUrl, home);
|
|
35
|
+
if (!existsSync(path))
|
|
36
|
+
return undefined;
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// A corrupt file is indistinguishable from no file for our purposes: the
|
|
42
|
+
// next sign-in overwrites it. Failing here would strand the customer.
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function writeSession(serverUrl, session, home) {
|
|
47
|
+
const dir = storeRoot(home);
|
|
48
|
+
mkdirSync(dir, { recursive: true, mode: STORE_DIR_MODE });
|
|
49
|
+
chmodSync(dir, STORE_DIR_MODE);
|
|
50
|
+
const path = storePath(serverUrl, home);
|
|
51
|
+
writeFileSync(path, `${JSON.stringify(session, null, 2)}\n`, { mode: STORE_FILE_MODE });
|
|
52
|
+
chmodSync(path, STORE_FILE_MODE);
|
|
53
|
+
}
|
|
54
|
+
/** Merge a patch into the stored session. Returns the session as written. */
|
|
55
|
+
export function updateSession(serverUrl, patch, home) {
|
|
56
|
+
const next = {
|
|
57
|
+
...(readSession(serverUrl, home) ?? { serverUrl }),
|
|
58
|
+
...patch,
|
|
59
|
+
serverUrl,
|
|
60
|
+
};
|
|
61
|
+
writeSession(serverUrl, next, home);
|
|
62
|
+
return next;
|
|
63
|
+
}
|
|
64
|
+
/** `--logout`. Returns whether there was anything to delete. */
|
|
65
|
+
export function clearSession(serverUrl, home) {
|
|
66
|
+
const path = storePath(serverUrl, home);
|
|
67
|
+
if (!existsSync(path))
|
|
68
|
+
return false;
|
|
69
|
+
rmSync(path, { force: true });
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Whether the stored access token can be used without talking to the
|
|
74
|
+
* authorization server first. An absent `expiresAt` means the server declared
|
|
75
|
+
* no expiry — the token is good until it is refused.
|
|
76
|
+
*/
|
|
77
|
+
export function tokensAreFresh(session, now = Date.now()) {
|
|
78
|
+
if (!session?.tokens?.access_token)
|
|
79
|
+
return false;
|
|
80
|
+
if (session.expiresAt === undefined)
|
|
81
|
+
return true;
|
|
82
|
+
return session.expiresAt - now > EXPIRY_SKEW_MS;
|
|
83
|
+
}
|
|
84
|
+
/** Epoch ms for a token response's `expires_in`, or undefined if it had none. */
|
|
85
|
+
export function expiryFromTokens(tokens, now = Date.now()) {
|
|
86
|
+
return typeof tokens.expires_in === 'number' ? now + tokens.expires_in * 1000 : undefined;
|
|
87
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
/** The published version, read from the package manifest beside `dist/`. */
|
|
3
|
+
export function readPackageVersion() {
|
|
4
|
+
try {
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const pkg = require('../package.json');
|
|
7
|
+
return pkg.version ?? '0.0.0';
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return '0.0.0';
|
|
11
|
+
}
|
|
12
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@phenomenalorg/mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Phenomenal MCP bridge — connects a stdio-only MCP client to the Phenomenal MCP server over Streamable HTTP, with browser sign-in.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://phenomenal.org",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/PTO-pro/ptopro.git",
|
|
11
|
+
"directory": "packages/phenomenal-mcp"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"phenomenal",
|
|
15
|
+
"mcp",
|
|
16
|
+
"modelcontextprotocol",
|
|
17
|
+
"stdio",
|
|
18
|
+
"bridge"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"phenomenal-mcp": "./dist/cli.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.build.json && chmod +x dist/cli.js",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "pnpm run build && vitest run"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
43
|
+
"open": "^11.0.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^22.10.0",
|
|
47
|
+
"typescript": "^5.6.0",
|
|
48
|
+
"vitest": "^2.1.0"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
}
|
|
53
|
+
}
|