@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 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]);
271
+ }
272
+ function describe(error) {
273
+ return error instanceof Error ? error.message : String(error);
274
+ }
@@ -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
+ }
@@ -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';
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ /** The published version, read from the package manifest beside `dist/`. */
2
+ export declare function readPackageVersion(): string;
@@ -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
+ }