@a-venue/guideon-mcp 0.1.0 → 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 CHANGED
@@ -23,14 +23,17 @@ Anything else (Cursor, Claude Desktop, Codex, VS Code), in its MCP settings:
23
23
  }
24
24
  ```
25
25
 
26
- The first run opens your browser; sign in to GuideOn and choose **Allow access**. The token is kept in
26
+ The first run opens your browser; sign in to GuideOn and choose **Allow access**. The bridge finds the sign-in
27
+ itself (OAuth discovery and dynamic client registration), so it works with any MCP server that follows the
28
+ same rules. An agent that can connect to a server by address needs no bridge:
29
+ `claude mcp add --transport http guideon https://guideon.askn.us/api/mcp`. The token is kept in
27
30
  `~/.guideon/mcp-token.json` (readable by you only) and lasts seven days.
28
31
 
29
32
  ## Options
30
33
 
31
34
  | Flag | Environment variable | Default |
32
35
  | --- | --- | --- |
33
- | `--url` | `GUIDEON_MCP_URL` | `https://api.guideon.loretia.com/api/mcp` |
36
+ | `--url` | `GUIDEON_MCP_URL` | `https://guideon.askn.us/api/mcp` |
34
37
  | `--token` | `GUIDEON_MCP_TOKEN` | the cached sign-in |
35
38
 
36
39
  Logs go to stderr; stdout carries the protocol.
package/dist/auth.js CHANGED
@@ -4,7 +4,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from '
4
4
  import { createServer } from 'node:http';
5
5
  import { homedir } from 'node:os';
6
6
  import { join } from 'node:path';
7
- export const DEFAULT_URL = 'https://api.guideon.loretia.com/api/mcp';
7
+ export const DEFAULT_URL = 'https://guideon.askn.us/api/mcp';
8
8
  const LOGIN_TIMEOUT_MS = 120_000;
9
9
  const log = (message) => console.error(`[guideon-mcp] ${message}`);
10
10
  const configDir = () => process.env.GUIDEON_MCP_HOME ?? join(homedir(), '.guideon');
@@ -32,7 +32,9 @@ export function saveToken(url, accessToken, expiresInSeconds) {
32
32
  ...readTokens(),
33
33
  [url]: {
34
34
  accessToken,
35
- expiresAt: expiresInSeconds ? Date.now() + expiresInSeconds * 1000 : undefined,
35
+ expiresAt: expiresInSeconds
36
+ ? Date.now() + expiresInSeconds * 1000
37
+ : undefined,
36
38
  },
37
39
  }, null, 2), { mode: 0o600 });
38
40
  chmodSync(tokenFile(), 0o600);
@@ -51,16 +53,70 @@ export function openBrowser(url) {
51
53
  }
52
54
  const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
53
55
  const page = (title, body) => `<!doctype html><meta charset="utf-8"><title>GuideOn</title><body style="font-family:system-ui;text-align:center;padding:64px"><h2>${escapeHtml(title)}</h2><p>${escapeHtml(body)}</p></body>`;
56
+ const getJson = async (url) => {
57
+ const response = await fetch(url, {
58
+ headers: { Accept: 'application/json' },
59
+ });
60
+ if (!response.ok) {
61
+ throw new Error(`${url} answered ${response.status}.`);
62
+ }
63
+ return (await response.json());
64
+ };
65
+ /**
66
+ * Finds the sign-in endpoints the way MCP's OAuth says to: an unauthenticated request is
67
+ * answered 401 with the address of the resource metadata (RFC 9728), that names the
68
+ * authorization server, and its metadata (RFC 8414) lists the endpoints. Works against any
69
+ * server that follows the same rules, not only GuideOn.
70
+ */
71
+ export async function discover(mcpUrl) {
72
+ const target = new URL(mcpUrl);
73
+ const probe = await fetch(mcpUrl, {
74
+ method: 'POST',
75
+ headers: {
76
+ 'Content-Type': 'application/json',
77
+ Accept: 'application/json, text/event-stream',
78
+ },
79
+ body: JSON.stringify({
80
+ jsonrpc: '2.0',
81
+ id: 0,
82
+ method: 'initialize',
83
+ params: {
84
+ protocolVersion: '2025-03-26',
85
+ capabilities: {},
86
+ clientInfo: { name: 'guideon-mcp', version: '0' },
87
+ },
88
+ }),
89
+ });
90
+ const advertised = /resource_metadata="([^"]+)"/.exec(probe.headers.get('www-authenticate') ?? '')?.[1];
91
+ const resource = await getJson(advertised ??
92
+ `${target.origin}/.well-known/oauth-protected-resource${target.pathname}`);
93
+ const issuer = resource.authorization_servers?.[0];
94
+ if (!issuer) {
95
+ throw new Error(`${mcpUrl} does not say where to sign in.`);
96
+ }
97
+ const at = new URL(issuer);
98
+ const server = await getJson(`${at.origin}/.well-known/oauth-authorization-server${at.pathname === '/' ? '' : at.pathname}`);
99
+ if (!server.authorization_endpoint || !server.token_endpoint) {
100
+ throw new Error(`${issuer} is missing its sign-in endpoints.`);
101
+ }
102
+ return {
103
+ authorization: server.authorization_endpoint,
104
+ token: server.token_endpoint,
105
+ registration: server.registration_endpoint,
106
+ };
107
+ }
54
108
  /**
55
109
  * Sign-in by the browser, OAuth authorization-code flow with PKCE: a one-shot server on a
56
- * random local port catches the redirect, the code is traded for a token at `tokenUrl`.
57
- * `open` is injectable so a test can play the browser.
110
+ * random local port catches the redirect, registers itself as a client there (RFC 7591), and
111
+ * trades the code for a token. `open` is injectable so a test can play the browser.
58
112
  */
59
- export function signIn(authorizeUrl, tokenUrl, open = openBrowser) {
113
+ export function signIn(endpoints, resource, open = openBrowser) {
60
114
  const verifier = randomBytes(32).toString('base64url');
61
115
  const challenge = createHash('sha256').update(verifier).digest('base64url');
62
116
  const state = randomBytes(16).toString('hex');
117
+ let clientId;
63
118
  return new Promise((resolve, reject) => {
119
+ const redirectUri = () => `http://127.0.0.1:${server.address().port}/callback`;
64
120
  const server = createServer((req, res) => {
65
121
  const url = new URL(req.url ?? '/', 'http://127.0.0.1');
66
122
  if (url.pathname !== '/callback') {
@@ -68,7 +124,9 @@ export function signIn(authorizeUrl, tokenUrl, open = openBrowser) {
68
124
  return;
69
125
  }
70
126
  const finish = (status, title, body, outcome) => {
71
- res.writeHead(status, { 'Content-Type': 'text/html' }).end(page(title, body));
127
+ res
128
+ .writeHead(status, { 'Content-Type': 'text/html' })
129
+ .end(page(title, body));
72
130
  clearTimeout(timer);
73
131
  server.close();
74
132
  outcome();
@@ -76,18 +134,23 @@ export function signIn(authorizeUrl, tokenUrl, open = openBrowser) {
76
134
  const code = url.searchParams.get('code');
77
135
  const error = url.searchParams.get('error');
78
136
  if (error || !code || url.searchParams.get('state') !== state) {
79
- finish(400, 'Sign-in did not complete', error ?? 'Please try again.', () => reject(new Error(error ? `Sign-in refused: ${error}` : 'Sign-in callback was invalid.')));
137
+ finish(400, 'Sign-in did not complete', error ?? 'Please try again.', () => reject(new Error(error
138
+ ? `Sign-in refused: ${error}`
139
+ : 'Sign-in callback was invalid.')));
80
140
  return;
81
141
  }
82
- const redirectUri = `http://127.0.0.1:${server.address().port}/callback`;
83
- fetch(tokenUrl, {
142
+ fetch(endpoints.token, {
84
143
  method: 'POST',
85
- headers: { 'Content-Type': 'application/json' },
86
- body: JSON.stringify({
144
+ headers: {
145
+ 'Content-Type': 'application/x-www-form-urlencoded',
146
+ Accept: 'application/json',
147
+ },
148
+ body: new URLSearchParams({
87
149
  grant_type: 'authorization_code',
88
150
  code,
89
151
  code_verifier: verifier,
90
- redirect_uri: redirectUri,
152
+ redirect_uri: redirectUri(),
153
+ ...(clientId ? { client_id: clientId } : {}),
91
154
  }),
92
155
  })
93
156
  .then(async (response) => {
@@ -95,7 +158,10 @@ export function signIn(authorizeUrl, tokenUrl, open = openBrowser) {
95
158
  throw new Error(`Token exchange failed (${response.status}): ${await response.text()}`);
96
159
  }
97
160
  const body = (await response.json());
98
- finish(200, 'You are signed in', 'You can close this tab and go back to your agent.', () => resolve({ accessToken: body.access_token, expiresIn: body.expires_in }));
161
+ finish(200, 'You are signed in', 'You can close this tab and go back to your agent.', () => resolve({
162
+ accessToken: body.access_token,
163
+ expiresIn: body.expires_in,
164
+ }));
99
165
  })
100
166
  .catch((cause) => finish(500, 'Sign-in did not complete', 'Please try again.', () => reject(cause)));
101
167
  });
@@ -105,13 +171,41 @@ export function signIn(authorizeUrl, tokenUrl, open = openBrowser) {
105
171
  }, LOGIN_TIMEOUT_MS);
106
172
  server.on('error', reject);
107
173
  server.listen(0, '127.0.0.1', () => {
108
- const redirectUri = `http://127.0.0.1:${server.address().port}/callback`;
109
- const target = new URL(authorizeUrl);
110
- target.searchParams.set('redirect_uri', redirectUri);
111
- target.searchParams.set('code_challenge', challenge);
112
- target.searchParams.set('state', state);
113
- log('Opening your browser to sign in to GuideOn...');
114
- open(target.toString());
174
+ void (async () => {
175
+ if (endpoints.registration) {
176
+ const response = await fetch(endpoints.registration, {
177
+ method: 'POST',
178
+ headers: { 'Content-Type': 'application/json' },
179
+ body: JSON.stringify({
180
+ client_name: 'guideon-mcp on your computer',
181
+ redirect_uris: [redirectUri()],
182
+ }),
183
+ });
184
+ if (!response.ok) {
185
+ throw new Error(`Registering this client failed (${response.status}).`);
186
+ }
187
+ clientId = (await response.json())
188
+ .client_id;
189
+ }
190
+ const target = new URL(endpoints.authorization);
191
+ for (const [key, value] of Object.entries({
192
+ ...(clientId ? { client_id: clientId } : {}),
193
+ response_type: 'code',
194
+ redirect_uri: redirectUri(),
195
+ code_challenge: challenge,
196
+ code_challenge_method: 'S256',
197
+ state,
198
+ resource,
199
+ })) {
200
+ target.searchParams.set(key, value);
201
+ }
202
+ log('Opening your browser to sign in to GuideOn...');
203
+ open(target.toString());
204
+ })().catch((cause) => {
205
+ clearTimeout(timer);
206
+ server.close();
207
+ reject(cause instanceof Error ? cause : new Error(String(cause)));
208
+ });
115
209
  });
116
210
  });
117
211
  }
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
4
4
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
5
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
6
  import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
7
- import { DEFAULT_URL, saveToken, signIn, storedToken } from './auth.js';
7
+ import { DEFAULT_URL, discover, saveToken, signIn, storedToken, } from './auth.js';
8
8
  // stdout is the protocol; everything a person should read goes to stderr.
9
9
  function option(name) {
10
10
  const args = process.argv.slice(2);
@@ -12,22 +12,22 @@ function option(name) {
12
12
  if (at === -1) {
13
13
  return undefined;
14
14
  }
15
- return args[at].includes('=') ? args[at].split('=').slice(1).join('=') : args[at + 1];
15
+ return args[at].includes('=')
16
+ ? args[at].split('=').slice(1).join('=')
17
+ : args[at + 1];
16
18
  }
17
19
  async function connect(url, token) {
18
20
  const client = new Client({ name: 'guideon-mcp', version: '0.1.0' });
19
21
  await client.connect(new StreamableHTTPClientTransport(new URL(url), {
20
- requestInit: token ? { headers: { Authorization: `Bearer ${token}` } } : undefined,
22
+ requestInit: token
23
+ ? { headers: { Authorization: `Bearer ${token}` } }
24
+ : undefined,
21
25
  }));
22
26
  return client;
23
27
  }
24
28
  const isUnauthorized = (error) => /\b401\b|unauthori[sz]ed/i.test(error instanceof Error ? error.message : String(error));
25
29
  async function login(url) {
26
- const info = (await (await fetch(url)).json());
27
- if (!info.authorization_endpoint) {
28
- throw new Error(`${url} is not a GuideOn MCP server.`);
29
- }
30
- const { accessToken, expiresIn } = await signIn(info.authorization_endpoint, `${url.replace(/\/$/, '')}/oauth/token`);
30
+ const { accessToken, expiresIn } = await signIn(await discover(url), url);
31
31
  saveToken(url, accessToken, expiresIn);
32
32
  return accessToken;
33
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-venue/guideon-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Connect Claude, Cursor, Codex and other AI agents to GuideOn: a stdio bridge to GuideOn's remote MCP server, with browser sign-in.",
5
5
  "keywords": [
6
6
  "mcp",