@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 +5 -2
- package/dist/auth.js +114 -20
- package/dist/cli.js +8 -8
- package/package.json +1 -1
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
|
|
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://
|
|
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://
|
|
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
|
|
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,
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
83
|
-
fetch(tokenUrl, {
|
|
142
|
+
fetch(endpoints.token, {
|
|
84
143
|
method: 'POST',
|
|
85
|
-
headers: {
|
|
86
|
-
|
|
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({
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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('=')
|
|
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
|
|
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
|
|
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