@a-venue/guideon-mcp 0.0.0-stage → 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 +43 -2
- package/dist/auth.js +211 -0
- package/dist/cli.js +56 -0
- package/package.json +41 -3
package/README.md
CHANGED
|
@@ -1,3 +1,44 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @a-venue/guideon-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Connect Claude, Cursor, Codex and other AI agents to [GuideOn](https://guideon.loretia.com). It is a small
|
|
4
|
+
bridge: your agent talks to it over stdio, and it talks to GuideOn's remote MCP server, signing you in
|
|
5
|
+
through your browser the first time. The agent acts **as you**: it sees your organizations and can change
|
|
6
|
+
what your role allows.
|
|
7
|
+
|
|
8
|
+
## Use it
|
|
9
|
+
|
|
10
|
+
Claude Code:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
claude mcp add guideon -- npx -y @a-venue/guideon-mcp
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Anything else (Cursor, Claude Desktop, Codex, VS Code), in its MCP settings:
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"mcpServers": {
|
|
21
|
+
"guideon": { "command": "npx", "args": ["-y", "@a-venue/guideon-mcp"] }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
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
|
|
30
|
+
`~/.guideon/mcp-token.json` (readable by you only) and lasts seven days.
|
|
31
|
+
|
|
32
|
+
## Options
|
|
33
|
+
|
|
34
|
+
| Flag | Environment variable | Default |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `--url` | `GUIDEON_MCP_URL` | `https://guideon.askn.us/api/mcp` |
|
|
37
|
+
| `--token` | `GUIDEON_MCP_TOKEN` | the cached sign-in |
|
|
38
|
+
|
|
39
|
+
Logs go to stderr; stdout carries the protocol.
|
|
40
|
+
|
|
41
|
+
## Tools
|
|
42
|
+
|
|
43
|
+
`list_organizations`, `list_venues`, `get_venue`, `update_venue`, `list_pois`, `create_poi`, `update_poi`,
|
|
44
|
+
`list_avatars`, `list_experiences`, `list_content_items`, `search_knowledge`, `reindex_knowledge`.
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { exec } from 'node:child_process';
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
export const DEFAULT_URL = 'https://guideon.askn.us/api/mcp';
|
|
8
|
+
const LOGIN_TIMEOUT_MS = 120_000;
|
|
9
|
+
const log = (message) => console.error(`[guideon-mcp] ${message}`);
|
|
10
|
+
const configDir = () => process.env.GUIDEON_MCP_HOME ?? join(homedir(), '.guideon');
|
|
11
|
+
const tokenFile = () => join(configDir(), 'mcp-token.json');
|
|
12
|
+
function readTokens() {
|
|
13
|
+
try {
|
|
14
|
+
return existsSync(tokenFile())
|
|
15
|
+
? JSON.parse(readFileSync(tokenFile(), 'utf8'))
|
|
16
|
+
: {};
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** The cached token for this server, unless it has expired. */
|
|
23
|
+
export function storedToken(url, now = Date.now()) {
|
|
24
|
+
const entry = readTokens()[url];
|
|
25
|
+
return entry && (!entry.expiresAt || entry.expiresAt > now)
|
|
26
|
+
? entry.accessToken
|
|
27
|
+
: null;
|
|
28
|
+
}
|
|
29
|
+
export function saveToken(url, accessToken, expiresInSeconds) {
|
|
30
|
+
mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
31
|
+
writeFileSync(tokenFile(), JSON.stringify({
|
|
32
|
+
...readTokens(),
|
|
33
|
+
[url]: {
|
|
34
|
+
accessToken,
|
|
35
|
+
expiresAt: expiresInSeconds
|
|
36
|
+
? Date.now() + expiresInSeconds * 1000
|
|
37
|
+
: undefined,
|
|
38
|
+
},
|
|
39
|
+
}, null, 2), { mode: 0o600 });
|
|
40
|
+
chmodSync(tokenFile(), 0o600);
|
|
41
|
+
}
|
|
42
|
+
export function openBrowser(url) {
|
|
43
|
+
const command = process.platform === 'darwin'
|
|
44
|
+
? `open "${url}"`
|
|
45
|
+
: process.platform === 'win32'
|
|
46
|
+
? `start "" "${url}"`
|
|
47
|
+
: `xdg-open "${url}"`;
|
|
48
|
+
exec(command, (error) => {
|
|
49
|
+
if (error) {
|
|
50
|
+
log(`Open this address in your browser to sign in:\n${url}`);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
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
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Sign-in by the browser, OAuth authorization-code flow with PKCE: a one-shot server on a
|
|
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.
|
|
112
|
+
*/
|
|
113
|
+
export function signIn(endpoints, resource, open = openBrowser) {
|
|
114
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
115
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
116
|
+
const state = randomBytes(16).toString('hex');
|
|
117
|
+
let clientId;
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
const redirectUri = () => `http://127.0.0.1:${server.address().port}/callback`;
|
|
120
|
+
const server = createServer((req, res) => {
|
|
121
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
122
|
+
if (url.pathname !== '/callback') {
|
|
123
|
+
res.writeHead(404).end();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const finish = (status, title, body, outcome) => {
|
|
127
|
+
res
|
|
128
|
+
.writeHead(status, { 'Content-Type': 'text/html' })
|
|
129
|
+
.end(page(title, body));
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
server.close();
|
|
132
|
+
outcome();
|
|
133
|
+
};
|
|
134
|
+
const code = url.searchParams.get('code');
|
|
135
|
+
const error = url.searchParams.get('error');
|
|
136
|
+
if (error || !code || url.searchParams.get('state') !== state) {
|
|
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.')));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
fetch(endpoints.token, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: {
|
|
145
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
146
|
+
Accept: 'application/json',
|
|
147
|
+
},
|
|
148
|
+
body: new URLSearchParams({
|
|
149
|
+
grant_type: 'authorization_code',
|
|
150
|
+
code,
|
|
151
|
+
code_verifier: verifier,
|
|
152
|
+
redirect_uri: redirectUri(),
|
|
153
|
+
...(clientId ? { client_id: clientId } : {}),
|
|
154
|
+
}),
|
|
155
|
+
})
|
|
156
|
+
.then(async (response) => {
|
|
157
|
+
if (!response.ok) {
|
|
158
|
+
throw new Error(`Token exchange failed (${response.status}): ${await response.text()}`);
|
|
159
|
+
}
|
|
160
|
+
const body = (await response.json());
|
|
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
|
+
}));
|
|
165
|
+
})
|
|
166
|
+
.catch((cause) => finish(500, 'Sign-in did not complete', 'Please try again.', () => reject(cause)));
|
|
167
|
+
});
|
|
168
|
+
const timer = setTimeout(() => {
|
|
169
|
+
server.close();
|
|
170
|
+
reject(new Error('Sign-in timed out after two minutes.'));
|
|
171
|
+
}, LOGIN_TIMEOUT_MS);
|
|
172
|
+
server.on('error', reject);
|
|
173
|
+
server.listen(0, '127.0.0.1', () => {
|
|
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
|
+
});
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
3
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
4
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
7
|
+
import { DEFAULT_URL, discover, saveToken, signIn, storedToken, } from './auth.js';
|
|
8
|
+
// stdout is the protocol; everything a person should read goes to stderr.
|
|
9
|
+
function option(name) {
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const at = args.findIndex((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`));
|
|
12
|
+
if (at === -1) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
return args[at].includes('=')
|
|
16
|
+
? args[at].split('=').slice(1).join('=')
|
|
17
|
+
: args[at + 1];
|
|
18
|
+
}
|
|
19
|
+
async function connect(url, token) {
|
|
20
|
+
const client = new Client({ name: 'guideon-mcp', version: '0.1.0' });
|
|
21
|
+
await client.connect(new StreamableHTTPClientTransport(new URL(url), {
|
|
22
|
+
requestInit: token
|
|
23
|
+
? { headers: { Authorization: `Bearer ${token}` } }
|
|
24
|
+
: undefined,
|
|
25
|
+
}));
|
|
26
|
+
return client;
|
|
27
|
+
}
|
|
28
|
+
const isUnauthorized = (error) => /\b401\b|unauthori[sz]ed/i.test(error instanceof Error ? error.message : String(error));
|
|
29
|
+
async function login(url) {
|
|
30
|
+
const { accessToken, expiresIn } = await signIn(await discover(url), url);
|
|
31
|
+
saveToken(url, accessToken, expiresIn);
|
|
32
|
+
return accessToken;
|
|
33
|
+
}
|
|
34
|
+
async function main() {
|
|
35
|
+
const url = option('url') ?? process.env.GUIDEON_MCP_URL ?? DEFAULT_URL;
|
|
36
|
+
const given = option('token') ?? process.env.GUIDEON_MCP_TOKEN;
|
|
37
|
+
let remote;
|
|
38
|
+
try {
|
|
39
|
+
remote = await connect(url, given ?? storedToken(url));
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
// A token someone handed us is theirs to fix; ours we can renew.
|
|
43
|
+
if (given || !isUnauthorized(error)) {
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
remote = await connect(url, await login(url));
|
|
47
|
+
}
|
|
48
|
+
const local = new Server({ name: 'guideon', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
49
|
+
local.setRequestHandler(ListToolsRequestSchema, () => remote.listTools());
|
|
50
|
+
local.setRequestHandler(CallToolRequestSchema, (request) => remote.callTool(request.params));
|
|
51
|
+
await local.connect(new StdioServerTransport());
|
|
52
|
+
}
|
|
53
|
+
main().catch((error) => {
|
|
54
|
+
console.error('[guideon-mcp]', error instanceof Error ? error.message : error);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a-venue/guideon-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"guideon",
|
|
9
|
+
"claude",
|
|
10
|
+
"cursor",
|
|
11
|
+
"ai-agent"
|
|
12
|
+
],
|
|
13
|
+
"license": "UNLICENSED",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"guideon-mcp": "dist/cli.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"!dist/*.test.js",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@modelcontextprotocol/sdk": "^1.32.1"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.10.7",
|
|
31
|
+
"typescript": "5.9.3",
|
|
32
|
+
"@guideon/config": "1.0.0"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public",
|
|
36
|
+
"registry": "https://registry.npmjs.org/"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"lint": "eslint .",
|
|
42
|
+
"test": "tsc && node --test dist/*.test.js"
|
|
43
|
+
}
|
|
6
44
|
}
|