@entergram/mcp 0.1.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 +266 -0
- package/dist/cli/args.js +43 -0
- package/dist/cli/bin.js +118 -0
- package/dist/cli/bridge.js +81 -0
- package/dist/cli/browser.js +20 -0
- package/dist/cli/config.js +77 -0
- package/dist/cli/constants.js +7 -0
- package/dist/cli/oauth-callback.js +131 -0
- package/dist/cli/oauth-provider.js +170 -0
- package/dist/cli/print-config.js +31 -0
- package/dist/cli/remote-client.js +115 -0
- package/dist/cli/session-store.js +44 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# Entergram MCP
|
|
2
|
+
|
|
3
|
+
Official npm CLI for connecting local MCP hosts to the public Entergram MCP gateway.
|
|
4
|
+
|
|
5
|
+
This package is for local MCP hosts that can only launch local commands, such as Codex, Claude Desktop, and similar tools. It handles:
|
|
6
|
+
|
|
7
|
+
- OAuth login against the Entergram MCP gateway
|
|
8
|
+
- secure local token storage
|
|
9
|
+
- a local `stdio` bridge that proxies requests to the public Entergram MCP gateway
|
|
10
|
+
- ready-to-paste host config snippets
|
|
11
|
+
|
|
12
|
+
Architecture:
|
|
13
|
+
|
|
14
|
+
`Local MCP host -> entergram-mcp (stdio bridge) -> Entergram MCP gateway -> Entergram Public API`
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
Global install:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g @entergram/mcp
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or run it on demand:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx -y @entergram/mcp serve
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
Production:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
entergram-mcp login
|
|
36
|
+
entergram-mcp serve
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Development:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
entergram-mcp login --env dev
|
|
43
|
+
entergram-mcp serve --env dev
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
If you use a non-default OAuth client, pass it explicitly during login and in your MCP host config:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
entergram-mcp login --env dev --client-id entergram-ws-your-client-id
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Commands
|
|
53
|
+
|
|
54
|
+
Show help:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
entergram-mcp --help
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Login:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
entergram-mcp login
|
|
64
|
+
entergram-mcp login --env dev
|
|
65
|
+
entergram-mcp login --env dev --client-id entergram-ws-your-client-id
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Start the local bridge:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
entergram-mcp serve
|
|
72
|
+
entergram-mcp serve --env dev
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Show the currently authenticated actor:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
entergram-mcp whoami
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Clear the local session for the selected environment and client:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
entergram-mcp logout
|
|
85
|
+
entergram-mcp logout --env dev --client-id entergram-ws-your-client-id
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Print a host config snippet:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
entergram-mcp print-config
|
|
92
|
+
entergram-mcp print-config --format toml
|
|
93
|
+
entergram-mcp print-config --env dev --client-id entergram-ws-your-client-id --format toml
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Host Config
|
|
97
|
+
|
|
98
|
+
### JSON hosts
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
entergram-mcp print-config --name entergram
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Example output:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"mcpServers": {
|
|
109
|
+
"entergram": {
|
|
110
|
+
"command": "npx",
|
|
111
|
+
"args": ["-y", "@entergram/mcp", "serve"],
|
|
112
|
+
"env": {
|
|
113
|
+
"ENTERGRAM_MCP_ENV": "production",
|
|
114
|
+
"ENTERGRAM_MCP_CLIENT_ID": "entergram-mcp-cli",
|
|
115
|
+
"ENTERGRAM_MCP_SCOPE": "workspace.read members.read accounts.read contacts.read chats.read messages.read messages.write custom_fields.read custom_fields.write tickets.read offline_access"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### TOML hosts such as Codex
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
entergram-mcp print-config --format toml --name entergram-dev --env dev --client-id entergram-ws-your-client-id
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Example output:
|
|
129
|
+
|
|
130
|
+
```toml
|
|
131
|
+
[mcp_servers.entergram-dev]
|
|
132
|
+
command = "npx"
|
|
133
|
+
args = ["-y", "@entergram/mcp", "serve"]
|
|
134
|
+
|
|
135
|
+
[mcp_servers.entergram-dev.env]
|
|
136
|
+
ENTERGRAM_MCP_ENV = "dev"
|
|
137
|
+
ENTERGRAM_MCP_CLIENT_ID = "entergram-ws-your-client-id"
|
|
138
|
+
ENTERGRAM_MCP_SCOPE = "workspace.read members.read accounts.read contacts.read chats.read messages.read messages.write custom_fields.read custom_fields.write tickets.read offline_access"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
If your host already uses the globally installed binary, you can replace the command section with:
|
|
142
|
+
|
|
143
|
+
```toml
|
|
144
|
+
[mcp_servers.entergram-dev]
|
|
145
|
+
command = "entergram-mcp"
|
|
146
|
+
args = ["serve"]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Environment Overrides
|
|
150
|
+
|
|
151
|
+
You can override the built-in defaults with environment variables:
|
|
152
|
+
|
|
153
|
+
- `ENTERGRAM_MCP_ENV`
|
|
154
|
+
- `ENTERGRAM_MCP_GATEWAY_URL`
|
|
155
|
+
- `ENTERGRAM_MCP_CLIENT_ID`
|
|
156
|
+
- `ENTERGRAM_MCP_SCOPE`
|
|
157
|
+
- `ENTERGRAM_MCP_CALLBACK_PORT`
|
|
158
|
+
- `ENTERGRAM_MCP_CONFIG_DIR`
|
|
159
|
+
|
|
160
|
+
Default local OAuth callback:
|
|
161
|
+
|
|
162
|
+
`http://127.0.0.1:8787/oauth/callback`
|
|
163
|
+
|
|
164
|
+
## Built-in Environments
|
|
165
|
+
|
|
166
|
+
Production:
|
|
167
|
+
|
|
168
|
+
- gateway: `https://mcp.entergram.com/mcp`
|
|
169
|
+
- client id: `entergram-mcp-cli`
|
|
170
|
+
|
|
171
|
+
Development:
|
|
172
|
+
|
|
173
|
+
- gateway: `https://devmcp.entergram.com/mcp`
|
|
174
|
+
- client id: `entergram-dev-mcp-client`
|
|
175
|
+
|
|
176
|
+
## Local Session Storage
|
|
177
|
+
|
|
178
|
+
By default, Entergram MCP stores local session files in:
|
|
179
|
+
|
|
180
|
+
`~/.entergram-mcp/`
|
|
181
|
+
|
|
182
|
+
Each environment and client id gets its own session file, so you can keep separate sessions for:
|
|
183
|
+
|
|
184
|
+
- production vs dev
|
|
185
|
+
- personal vs workspace OAuth clients
|
|
186
|
+
- multiple workspace clients with different scopes
|
|
187
|
+
|
|
188
|
+
## Choosing the Right OAuth Client
|
|
189
|
+
|
|
190
|
+
Use a personal client when you want a seat-scoped local agent with safer read-only access.
|
|
191
|
+
|
|
192
|
+
Use a workspace client when you need broader scopes such as:
|
|
193
|
+
|
|
194
|
+
- `members.read`
|
|
195
|
+
- `messages.write`
|
|
196
|
+
- `custom_fields.read`
|
|
197
|
+
- `custom_fields.write`
|
|
198
|
+
|
|
199
|
+
If your consent screen does not show the scopes you expect, the OAuth client itself is missing those `allowedScopes`. Update the client in Entergram first, then run login again.
|
|
200
|
+
|
|
201
|
+
## Troubleshooting
|
|
202
|
+
|
|
203
|
+
### `invalid_scope`
|
|
204
|
+
|
|
205
|
+
The selected OAuth client does not allow one or more requested scopes.
|
|
206
|
+
|
|
207
|
+
Fix:
|
|
208
|
+
|
|
209
|
+
1. update the OAuth client in Entergram so its `allowedScopes` include the requested scopes
|
|
210
|
+
2. run `entergram-mcp login` again
|
|
211
|
+
|
|
212
|
+
### `MCP startup failed: handshaking with MCP server failed`
|
|
213
|
+
|
|
214
|
+
Your MCP host started the local bridge, but the bridge could not authenticate or connect cleanly to the remote Entergram MCP gateway.
|
|
215
|
+
|
|
216
|
+
Fix:
|
|
217
|
+
|
|
218
|
+
1. run login first for the same environment and client id used by the host
|
|
219
|
+
2. make sure the host config includes the correct `ENTERGRAM_MCP_CLIENT_ID`
|
|
220
|
+
3. restart the MCP host after login
|
|
221
|
+
|
|
222
|
+
### `401` or expired token errors
|
|
223
|
+
|
|
224
|
+
The local session is stale or no longer refreshable.
|
|
225
|
+
|
|
226
|
+
Fix:
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
entergram-mcp logout --env dev --client-id entergram-ws-your-client-id
|
|
230
|
+
entergram-mcp login --env dev --client-id entergram-ws-your-client-id
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Browser opens during MCP host startup
|
|
234
|
+
|
|
235
|
+
For local MCP hosts, the intended flow is:
|
|
236
|
+
|
|
237
|
+
1. run `entergram-mcp login` yourself first
|
|
238
|
+
2. then let the host run `entergram-mcp serve`
|
|
239
|
+
|
|
240
|
+
The bridge is designed to reuse an existing local session instead of performing interactive login during the MCP handshake.
|
|
241
|
+
|
|
242
|
+
## Development
|
|
243
|
+
|
|
244
|
+
Build:
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
npm run build
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Typecheck:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
npm run typecheck
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
CLI smoke test:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
npm run smoke:cli
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Dry-run the publish tarball:
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
npm run pack:dry-run
|
|
266
|
+
```
|
package/dist/cli/args.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
function normalizeFlagName(value) {
|
|
2
|
+
return value.trim().replace(/^-+/, "");
|
|
3
|
+
}
|
|
4
|
+
export function parseCliArgs(argv) {
|
|
5
|
+
const tokens = [...argv];
|
|
6
|
+
const command = tokens[0] && !tokens[0].startsWith("-") ? tokens.shift() : "serve";
|
|
7
|
+
const flags = {};
|
|
8
|
+
const positionals = [];
|
|
9
|
+
while (tokens.length > 0) {
|
|
10
|
+
const token = tokens.shift();
|
|
11
|
+
if (!token.startsWith("-")) {
|
|
12
|
+
positionals.push(token);
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (token.startsWith("--")) {
|
|
16
|
+
const [rawName, inlineValue] = token.split("=", 2);
|
|
17
|
+
const name = normalizeFlagName(rawName);
|
|
18
|
+
if (inlineValue !== undefined) {
|
|
19
|
+
flags[name] = inlineValue;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const next = tokens[0];
|
|
23
|
+
if (!next || next.startsWith("-")) {
|
|
24
|
+
flags[name] = true;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
flags[name] = tokens.shift();
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const name = normalizeFlagName(token);
|
|
31
|
+
const next = tokens[0];
|
|
32
|
+
if (!next || next.startsWith("-")) {
|
|
33
|
+
flags[name] = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
flags[name] = tokens.shift();
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
command,
|
|
40
|
+
flags,
|
|
41
|
+
positionals,
|
|
42
|
+
};
|
|
43
|
+
}
|
package/dist/cli/bin.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { EntergramOAuthProvider } from "./oauth-provider.js";
|
|
3
|
+
import { parseCliArgs } from "./args.js";
|
|
4
|
+
import { startLocalBridge } from "./bridge.js";
|
|
5
|
+
import { resolveCliRuntimeConfig } from "./config.js";
|
|
6
|
+
import { ENTERGRAM_HOST_NAME } from "./constants.js";
|
|
7
|
+
import { buildHostConfigSnippet, buildHostConfigToml, } from "./print-config.js";
|
|
8
|
+
import { EntergramRemoteClient } from "./remote-client.js";
|
|
9
|
+
import { EntergramSessionStore } from "./session-store.js";
|
|
10
|
+
function printHelp() {
|
|
11
|
+
process.stdout.write(`Entergram MCP CLI
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
entergram-mcp serve
|
|
15
|
+
entergram-mcp login
|
|
16
|
+
entergram-mcp whoami
|
|
17
|
+
entergram-mcp logout
|
|
18
|
+
entergram-mcp print-config [--name entergram]
|
|
19
|
+
|
|
20
|
+
Flags:
|
|
21
|
+
--env dev|production
|
|
22
|
+
--gateway-url https://custom.example.com/mcp
|
|
23
|
+
--client-id entergram-mcp-cli
|
|
24
|
+
--scope "workspace.read ..."
|
|
25
|
+
--callback-port 8787
|
|
26
|
+
--config-dir /custom/path
|
|
27
|
+
--format json|toml
|
|
28
|
+
`);
|
|
29
|
+
}
|
|
30
|
+
function getConfigFormat(flags) {
|
|
31
|
+
const value = flags.format;
|
|
32
|
+
if (value === undefined) {
|
|
33
|
+
return "json";
|
|
34
|
+
}
|
|
35
|
+
if (typeof value !== "string") {
|
|
36
|
+
throw new Error('Expected "--format" to be either "json" or "toml".');
|
|
37
|
+
}
|
|
38
|
+
if (value === "json" || value === "toml") {
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`Unsupported Entergram MCP config format: ${value}`);
|
|
42
|
+
}
|
|
43
|
+
async function withRemoteClient(flags, callback) {
|
|
44
|
+
const config = resolveCliRuntimeConfig(flags);
|
|
45
|
+
const store = new EntergramSessionStore(config.sessionFilePath);
|
|
46
|
+
const provider = new EntergramOAuthProvider(config, store);
|
|
47
|
+
const client = new EntergramRemoteClient(config, provider);
|
|
48
|
+
try {
|
|
49
|
+
return await callback(client, store);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await client.close().catch(() => undefined);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function run() {
|
|
56
|
+
const parsed = parseCliArgs(process.argv.slice(2));
|
|
57
|
+
if (parsed.flags.help === true || parsed.flags.h === true) {
|
|
58
|
+
printHelp();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const config = resolveCliRuntimeConfig(parsed.flags);
|
|
62
|
+
switch (parsed.command) {
|
|
63
|
+
case "help":
|
|
64
|
+
case "--help":
|
|
65
|
+
case "-h":
|
|
66
|
+
printHelp();
|
|
67
|
+
return;
|
|
68
|
+
case "serve":
|
|
69
|
+
await startLocalBridge(config);
|
|
70
|
+
return;
|
|
71
|
+
case "login":
|
|
72
|
+
await withRemoteClient(parsed.flags, async (client) => {
|
|
73
|
+
await client.connect({ interactive: true });
|
|
74
|
+
const me = await client.callTool({
|
|
75
|
+
arguments: {},
|
|
76
|
+
name: "entergram_get_me",
|
|
77
|
+
});
|
|
78
|
+
process.stdout.write(`${JSON.stringify(me.structuredContent ?? me, null, 2)}\n`);
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
case "whoami":
|
|
82
|
+
await withRemoteClient(parsed.flags, async (client) => {
|
|
83
|
+
await client.connect({ interactive: true });
|
|
84
|
+
const me = await client.callTool({
|
|
85
|
+
arguments: {},
|
|
86
|
+
name: "entergram_get_me",
|
|
87
|
+
});
|
|
88
|
+
process.stdout.write(`${JSON.stringify(me.structuredContent ?? me, null, 2)}\n`);
|
|
89
|
+
});
|
|
90
|
+
return;
|
|
91
|
+
case "logout":
|
|
92
|
+
await withRemoteClient(parsed.flags, async (_client, store) => {
|
|
93
|
+
await store.clear();
|
|
94
|
+
process.stdout.write(`Cleared local Entergram MCP session for ${config.environment} (${config.clientId}).\n`);
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
case "print-config": {
|
|
98
|
+
const configuredName = typeof parsed.flags.name === "string" && parsed.flags.name.trim()
|
|
99
|
+
? parsed.flags.name.trim()
|
|
100
|
+
: "entergram";
|
|
101
|
+
const format = getConfigFormat(parsed.flags);
|
|
102
|
+
if (format === "toml") {
|
|
103
|
+
process.stdout.write(buildHostConfigToml(config, configuredName));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const snippet = buildHostConfigSnippet(config, configuredName);
|
|
107
|
+
process.stdout.write(`${JSON.stringify(snippet, null, 2)}\n`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
default:
|
|
111
|
+
throw new Error(`Unknown Entergram MCP command "${parsed.command}". Run "${ENTERGRAM_HOST_NAME.toLowerCase().replaceAll(" ", "-")} help" for usage.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
void run().catch((error) => {
|
|
115
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
116
|
+
process.stderr.write(`${message}\n`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema, PromptListChangedNotificationSchema, ReadResourceRequestSchema, ResourceListChangedNotificationSchema, ResourceUpdatedNotificationSchema, SubscribeRequestSchema, ToolListChangedNotificationSchema, UnsubscribeRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
import { CLI_VERSION, ENTERGRAM_HOST_NAME } from "./constants.js";
|
|
5
|
+
import { EntergramOAuthProvider } from "./oauth-provider.js";
|
|
6
|
+
import { EntergramRemoteClient } from "./remote-client.js";
|
|
7
|
+
import { EntergramSessionStore } from "./session-store.js";
|
|
8
|
+
function createCapabilities(remoteClient) {
|
|
9
|
+
const capabilities = remoteClient.connectedClient.getServerCapabilities();
|
|
10
|
+
return {
|
|
11
|
+
prompts: capabilities?.prompts,
|
|
12
|
+
resources: capabilities?.resources,
|
|
13
|
+
tools: capabilities?.tools ?? {},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function buildInstructions(remoteClient) {
|
|
17
|
+
const remoteInstructions = remoteClient.connectedClient.getInstructions();
|
|
18
|
+
const bridgeInstructions = "This is the local stdio Entergram MCP bridge. It transparently proxies requests to the public Entergram MCP gateway over OAuth-protected Streamable HTTP.";
|
|
19
|
+
return remoteInstructions
|
|
20
|
+
? `${remoteInstructions}\n\n${bridgeInstructions}`
|
|
21
|
+
: bridgeInstructions;
|
|
22
|
+
}
|
|
23
|
+
export async function startLocalBridge(config) {
|
|
24
|
+
const store = new EntergramSessionStore(config.sessionFilePath);
|
|
25
|
+
const authProvider = new EntergramOAuthProvider(config, store);
|
|
26
|
+
const remoteClient = new EntergramRemoteClient(config, authProvider);
|
|
27
|
+
try {
|
|
28
|
+
await remoteClient.connect({ interactive: false });
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
32
|
+
throw new Error(`Entergram MCP bridge could not connect to the remote gateway. Run "entergram-mcp login --env ${config.environment} --client-id ${config.clientId}" first, then restart your MCP host. Original error: ${message}`);
|
|
33
|
+
}
|
|
34
|
+
const server = new Server({
|
|
35
|
+
name: ENTERGRAM_HOST_NAME,
|
|
36
|
+
version: CLI_VERSION,
|
|
37
|
+
}, {
|
|
38
|
+
capabilities: createCapabilities(remoteClient),
|
|
39
|
+
instructions: buildInstructions(remoteClient),
|
|
40
|
+
});
|
|
41
|
+
server.setRequestHandler(ListToolsRequestSchema, async (request) => remoteClient.listTools(request.params));
|
|
42
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => remoteClient.callTool(request.params));
|
|
43
|
+
if (remoteClient.connectedClient.getServerCapabilities()?.prompts) {
|
|
44
|
+
server.setRequestHandler(ListPromptsRequestSchema, async (request) => remoteClient.listPrompts(request.params));
|
|
45
|
+
server.setRequestHandler(GetPromptRequestSchema, async (request) => remoteClient.getPrompt(request.params));
|
|
46
|
+
}
|
|
47
|
+
if (remoteClient.connectedClient.getServerCapabilities()?.resources) {
|
|
48
|
+
server.setRequestHandler(ListResourcesRequestSchema, async (request) => remoteClient.listResources(request.params));
|
|
49
|
+
server.setRequestHandler(ListResourceTemplatesRequestSchema, async (request) => remoteClient.listResourceTemplates(request.params));
|
|
50
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => remoteClient.readResource(request.params));
|
|
51
|
+
if (remoteClient.connectedClient.getServerCapabilities()?.resources?.subscribe) {
|
|
52
|
+
server.setRequestHandler(SubscribeRequestSchema, async (request) => remoteClient.subscribeResource(request.params));
|
|
53
|
+
server.setRequestHandler(UnsubscribeRequestSchema, async (request) => remoteClient.unsubscribeResource(request.params));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
remoteClient.connectedClient.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
|
57
|
+
await server.sendToolListChanged();
|
|
58
|
+
});
|
|
59
|
+
remoteClient.connectedClient.setNotificationHandler(PromptListChangedNotificationSchema, async () => {
|
|
60
|
+
await server.sendPromptListChanged();
|
|
61
|
+
});
|
|
62
|
+
remoteClient.connectedClient.setNotificationHandler(ResourceListChangedNotificationSchema, async () => {
|
|
63
|
+
await server.sendResourceListChanged();
|
|
64
|
+
});
|
|
65
|
+
remoteClient.connectedClient.setNotificationHandler(ResourceUpdatedNotificationSchema, async (notification) => {
|
|
66
|
+
await server.sendResourceUpdated(notification.params);
|
|
67
|
+
});
|
|
68
|
+
const transport = new StdioServerTransport();
|
|
69
|
+
await server.connect(transport);
|
|
70
|
+
const close = async () => {
|
|
71
|
+
await remoteClient.close();
|
|
72
|
+
await server.close().catch(() => undefined);
|
|
73
|
+
await transport.close().catch(() => undefined);
|
|
74
|
+
};
|
|
75
|
+
process.once("SIGINT", () => {
|
|
76
|
+
void close().finally(() => process.exit(0));
|
|
77
|
+
});
|
|
78
|
+
process.once("SIGTERM", () => {
|
|
79
|
+
void close().finally(() => process.exit(0));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
export async function openBrowser(url) {
|
|
5
|
+
try {
|
|
6
|
+
if (process.platform === "darwin") {
|
|
7
|
+
await execFileAsync("open", [url]);
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
if (process.platform === "win32") {
|
|
11
|
+
await execFileAsync("cmd", ["/c", "start", "", url]);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
await execFileAsync("xdg-open", [url]);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { DEFAULT_CALLBACK_HOST, DEFAULT_CALLBACK_PATH, DEFAULT_CALLBACK_PORT, DEFAULT_CONFIG_DIRECTORY_NAME, DEFAULT_SCOPE, } from "./constants.js";
|
|
4
|
+
const ENVIRONMENT_PRESETS = {
|
|
5
|
+
dev: {
|
|
6
|
+
clientId: "entergram-dev-mcp-client",
|
|
7
|
+
displayName: "Entergram MCP Dev",
|
|
8
|
+
gatewayUrl: "https://devmcp.entergram.com/mcp",
|
|
9
|
+
oauthIssuerUrl: "https://dev.entergram.com/",
|
|
10
|
+
},
|
|
11
|
+
production: {
|
|
12
|
+
clientId: "entergram-mcp-cli",
|
|
13
|
+
displayName: "Entergram MCP",
|
|
14
|
+
gatewayUrl: "https://mcp.entergram.com/mcp",
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
function getStringFlag(flags, name) {
|
|
18
|
+
const value = flags[name];
|
|
19
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
20
|
+
}
|
|
21
|
+
function getEnvironmentName(flags) {
|
|
22
|
+
const value = getStringFlag(flags, "env") ||
|
|
23
|
+
process.env.ENTERGRAM_MCP_ENV?.trim() ||
|
|
24
|
+
"production";
|
|
25
|
+
if (value === "dev" || value === "production") {
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`Unsupported Entergram MCP environment: ${value}`);
|
|
29
|
+
}
|
|
30
|
+
function parsePositiveInteger(value, fallback) {
|
|
31
|
+
if (!value) {
|
|
32
|
+
return fallback;
|
|
33
|
+
}
|
|
34
|
+
const parsed = Number.parseInt(value, 10);
|
|
35
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
36
|
+
throw new Error(`Expected a positive integer, received: ${value}`);
|
|
37
|
+
}
|
|
38
|
+
return parsed;
|
|
39
|
+
}
|
|
40
|
+
function sanitizeFileSegment(value) {
|
|
41
|
+
return value.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
42
|
+
}
|
|
43
|
+
export function resolveCliRuntimeConfig(flags) {
|
|
44
|
+
const environment = getEnvironmentName(flags);
|
|
45
|
+
const preset = ENVIRONMENT_PRESETS[environment];
|
|
46
|
+
const gatewayUrl = getStringFlag(flags, "gateway-url") ||
|
|
47
|
+
process.env.ENTERGRAM_MCP_GATEWAY_URL?.trim() ||
|
|
48
|
+
preset.gatewayUrl;
|
|
49
|
+
const clientId = getStringFlag(flags, "client-id") ||
|
|
50
|
+
process.env.ENTERGRAM_MCP_CLIENT_ID?.trim() ||
|
|
51
|
+
preset.clientId;
|
|
52
|
+
const oauthIssuerUrl = getStringFlag(flags, "oauth-issuer-url") ||
|
|
53
|
+
process.env.ENTERGRAM_MCP_OAUTH_ISSUER_URL?.trim() ||
|
|
54
|
+
preset.oauthIssuerUrl;
|
|
55
|
+
const scope = getStringFlag(flags, "scope") ||
|
|
56
|
+
process.env.ENTERGRAM_MCP_SCOPE?.trim() ||
|
|
57
|
+
DEFAULT_SCOPE;
|
|
58
|
+
const callbackPort = parsePositiveInteger(getStringFlag(flags, "callback-port") ||
|
|
59
|
+
process.env.ENTERGRAM_MCP_CALLBACK_PORT?.trim(), DEFAULT_CALLBACK_PORT);
|
|
60
|
+
const configDir = getStringFlag(flags, "config-dir") ||
|
|
61
|
+
process.env.ENTERGRAM_MCP_CONFIG_DIR?.trim() ||
|
|
62
|
+
path.join(os.homedir(), DEFAULT_CONFIG_DIRECTORY_NAME);
|
|
63
|
+
const gateway = new URL(gatewayUrl);
|
|
64
|
+
const callbackUrl = new URL(DEFAULT_CALLBACK_PATH, `http://${DEFAULT_CALLBACK_HOST}:${callbackPort}`).href;
|
|
65
|
+
const fileKey = sanitizeFileSegment(`${environment}-${gateway.host}-${clientId}`);
|
|
66
|
+
return {
|
|
67
|
+
callbackUrl,
|
|
68
|
+
clientId,
|
|
69
|
+
configDir,
|
|
70
|
+
displayName: preset.displayName,
|
|
71
|
+
environment,
|
|
72
|
+
gatewayUrl: gateway.href,
|
|
73
|
+
oauthIssuerUrl,
|
|
74
|
+
scope,
|
|
75
|
+
sessionFilePath: path.join(configDir, `${fileKey}.json`),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const CLI_VERSION = "0.1.0";
|
|
2
|
+
export const DEFAULT_SCOPE = "workspace.read members.read accounts.read contacts.read chats.read messages.read messages.write custom_fields.read custom_fields.write tickets.read offline_access";
|
|
3
|
+
export const DEFAULT_CALLBACK_HOST = "127.0.0.1";
|
|
4
|
+
export const DEFAULT_CALLBACK_PORT = 8787;
|
|
5
|
+
export const DEFAULT_CALLBACK_PATH = "/oauth/callback";
|
|
6
|
+
export const DEFAULT_CONFIG_DIRECTORY_NAME = ".entergram-mcp";
|
|
7
|
+
export const ENTERGRAM_HOST_NAME = "Entergram MCP";
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
function renderCallbackHtml(options) {
|
|
3
|
+
return `<!doctype html>
|
|
4
|
+
<html lang="en">
|
|
5
|
+
<head>
|
|
6
|
+
<meta charset="utf-8" />
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
8
|
+
<title>${options.title}</title>
|
|
9
|
+
<style>
|
|
10
|
+
:root {
|
|
11
|
+
color-scheme: light;
|
|
12
|
+
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
13
|
+
}
|
|
14
|
+
body {
|
|
15
|
+
margin: 0;
|
|
16
|
+
min-height: 100vh;
|
|
17
|
+
display: grid;
|
|
18
|
+
place-items: center;
|
|
19
|
+
background:
|
|
20
|
+
radial-gradient(circle at top, rgba(255, 186, 73, 0.28), transparent 42%),
|
|
21
|
+
linear-gradient(180deg, #fffaf2 0%, #f6efe4 100%);
|
|
22
|
+
color: #2f2418;
|
|
23
|
+
}
|
|
24
|
+
main {
|
|
25
|
+
width: min(560px, calc(100vw - 40px));
|
|
26
|
+
padding: 32px;
|
|
27
|
+
border-radius: 24px;
|
|
28
|
+
background: rgba(255, 255, 255, 0.9);
|
|
29
|
+
box-shadow: 0 30px 80px rgba(78, 49, 20, 0.15);
|
|
30
|
+
}
|
|
31
|
+
h1 {
|
|
32
|
+
margin: 0 0 12px;
|
|
33
|
+
font-size: 28px;
|
|
34
|
+
line-height: 1.1;
|
|
35
|
+
}
|
|
36
|
+
p {
|
|
37
|
+
margin: 0;
|
|
38
|
+
font-size: 16px;
|
|
39
|
+
line-height: 1.6;
|
|
40
|
+
}
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
<main>
|
|
45
|
+
<h1>${options.title}</h1>
|
|
46
|
+
<p>${options.message}</p>
|
|
47
|
+
</main>
|
|
48
|
+
</body>
|
|
49
|
+
</html>`;
|
|
50
|
+
}
|
|
51
|
+
export async function waitForOAuthCallback(callbackUrl, timeoutMs, getExpectedState) {
|
|
52
|
+
const callback = new URL(callbackUrl);
|
|
53
|
+
const server = http.createServer();
|
|
54
|
+
await new Promise((resolve, reject) => {
|
|
55
|
+
server.once("error", reject);
|
|
56
|
+
server.listen(Number(callback.port), callback.hostname, () => resolve());
|
|
57
|
+
});
|
|
58
|
+
const result = await new Promise((resolve, reject) => {
|
|
59
|
+
const timeout = setTimeout(() => {
|
|
60
|
+
reject(new Error("Timed out waiting for Entergram OAuth callback."));
|
|
61
|
+
}, timeoutMs);
|
|
62
|
+
server.on("request", (request, response) => {
|
|
63
|
+
const requestUrl = new URL(request.url || "/", callback);
|
|
64
|
+
if (requestUrl.pathname !== callback.pathname) {
|
|
65
|
+
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
66
|
+
response.end("Not found");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const error = requestUrl.searchParams.get("error");
|
|
70
|
+
const errorDescription = requestUrl.searchParams.get("error_description") || undefined;
|
|
71
|
+
const code = requestUrl.searchParams.get("code");
|
|
72
|
+
const state = requestUrl.searchParams.get("state") || undefined;
|
|
73
|
+
const expectedState = getExpectedState?.();
|
|
74
|
+
clearTimeout(timeout);
|
|
75
|
+
if (error) {
|
|
76
|
+
response.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
77
|
+
response.end(renderCallbackHtml({
|
|
78
|
+
message: errorDescription || "The authorization flow was cancelled or failed.",
|
|
79
|
+
title: "Entergram MCP authorization failed",
|
|
80
|
+
}));
|
|
81
|
+
resolve({
|
|
82
|
+
error,
|
|
83
|
+
errorDescription,
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!code) {
|
|
88
|
+
response.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
89
|
+
response.end(renderCallbackHtml({
|
|
90
|
+
message: "No authorization code was returned to the Entergram MCP client.",
|
|
91
|
+
title: "Entergram MCP authorization failed",
|
|
92
|
+
}));
|
|
93
|
+
resolve({
|
|
94
|
+
error: "missing_code",
|
|
95
|
+
errorDescription: "No authorization code was returned.",
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (getExpectedState && (!expectedState || state !== expectedState)) {
|
|
100
|
+
response.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
101
|
+
response.end(renderCallbackHtml({
|
|
102
|
+
message: "The authorization response could not be verified. Please retry the Entergram MCP sign-in flow.",
|
|
103
|
+
title: "Entergram MCP authorization failed",
|
|
104
|
+
}));
|
|
105
|
+
resolve({
|
|
106
|
+
error: "invalid_state",
|
|
107
|
+
errorDescription: "The returned OAuth state did not match the active authorization request.",
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
112
|
+
response.end(renderCallbackHtml({
|
|
113
|
+
message: "Authorization completed. You can close this browser tab and return to your MCP host.",
|
|
114
|
+
title: "Entergram MCP connected",
|
|
115
|
+
}));
|
|
116
|
+
resolve({ code, state });
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
try {
|
|
120
|
+
if ("error" in result) {
|
|
121
|
+
throw new Error(result.errorDescription ||
|
|
122
|
+
`Entergram OAuth failed with error: ${result.error}`);
|
|
123
|
+
}
|
|
124
|
+
return result.code;
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await new Promise((resolve) => {
|
|
128
|
+
server.close(() => resolve());
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { openBrowser } from "./browser.js";
|
|
3
|
+
function trimTrailingSlash(value) {
|
|
4
|
+
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
5
|
+
}
|
|
6
|
+
export class EntergramOAuthProvider {
|
|
7
|
+
config;
|
|
8
|
+
store;
|
|
9
|
+
pendingState;
|
|
10
|
+
constructor(config, store) {
|
|
11
|
+
this.config = config;
|
|
12
|
+
this.store = store;
|
|
13
|
+
}
|
|
14
|
+
get redirectUrl() {
|
|
15
|
+
return this.config.callbackUrl;
|
|
16
|
+
}
|
|
17
|
+
get clientMetadata() {
|
|
18
|
+
return {
|
|
19
|
+
client_name: this.config.displayName,
|
|
20
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
21
|
+
redirect_uris: [this.config.callbackUrl],
|
|
22
|
+
response_types: ["code"],
|
|
23
|
+
scope: this.config.scope,
|
|
24
|
+
token_endpoint_auth_method: "none",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
async clientInformation() {
|
|
28
|
+
return {
|
|
29
|
+
client_id: this.config.clientId,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async tokens() {
|
|
33
|
+
return (await this.store.load())?.tokens;
|
|
34
|
+
}
|
|
35
|
+
async saveTokens(tokens) {
|
|
36
|
+
this.pendingState = undefined;
|
|
37
|
+
await this.store.save((current) => ({
|
|
38
|
+
clientId: this.config.clientId,
|
|
39
|
+
codeVerifier: current?.codeVerifier,
|
|
40
|
+
discoveryState: current?.discoveryState,
|
|
41
|
+
expectedState: undefined,
|
|
42
|
+
gatewayUrl: this.config.gatewayUrl,
|
|
43
|
+
lastAuthenticatedAt: new Date().toISOString(),
|
|
44
|
+
scope: this.config.scope,
|
|
45
|
+
tokens,
|
|
46
|
+
updatedAt: new Date().toISOString(),
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
async redirectToAuthorization(authorizationUrl) {
|
|
50
|
+
const opened = await openBrowser(authorizationUrl.href);
|
|
51
|
+
const output = opened
|
|
52
|
+
? `Opening browser for Entergram authorization: ${authorizationUrl.href}\n`
|
|
53
|
+
: `Open this URL to authorize Entergram MCP:\n${authorizationUrl.href}\n`;
|
|
54
|
+
process.stderr.write(output);
|
|
55
|
+
}
|
|
56
|
+
async saveCodeVerifier(codeVerifier) {
|
|
57
|
+
await this.store.save((current) => ({
|
|
58
|
+
clientId: this.config.clientId,
|
|
59
|
+
codeVerifier,
|
|
60
|
+
discoveryState: current?.discoveryState,
|
|
61
|
+
expectedState: current?.expectedState ?? this.pendingState,
|
|
62
|
+
gatewayUrl: this.config.gatewayUrl,
|
|
63
|
+
lastAuthenticatedAt: current?.lastAuthenticatedAt,
|
|
64
|
+
scope: this.config.scope,
|
|
65
|
+
tokens: current?.tokens,
|
|
66
|
+
updatedAt: new Date().toISOString(),
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
async codeVerifier() {
|
|
70
|
+
const codeVerifier = (await this.store.load())?.codeVerifier;
|
|
71
|
+
if (!codeVerifier) {
|
|
72
|
+
throw new Error("Entergram OAuth code verifier is missing from local session storage.");
|
|
73
|
+
}
|
|
74
|
+
return codeVerifier;
|
|
75
|
+
}
|
|
76
|
+
async invalidateCredentials(scope) {
|
|
77
|
+
this.pendingState = undefined;
|
|
78
|
+
if (scope === "all") {
|
|
79
|
+
await this.store.clear();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
await this.store.save((current) => ({
|
|
83
|
+
clientId: this.config.clientId,
|
|
84
|
+
codeVerifier: scope === "verifier" ? undefined : current?.codeVerifier,
|
|
85
|
+
discoveryState: scope === "discovery" ? undefined : current?.discoveryState,
|
|
86
|
+
expectedState: undefined,
|
|
87
|
+
gatewayUrl: this.config.gatewayUrl,
|
|
88
|
+
lastAuthenticatedAt: current?.lastAuthenticatedAt,
|
|
89
|
+
scope: this.config.scope,
|
|
90
|
+
tokens: scope === "tokens" ? undefined : current?.tokens,
|
|
91
|
+
updatedAt: new Date().toISOString(),
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
async validateResourceURL(_serverUrl, resource) {
|
|
95
|
+
const expected = new URL(this.config.gatewayUrl);
|
|
96
|
+
if (!resource) {
|
|
97
|
+
return expected;
|
|
98
|
+
}
|
|
99
|
+
const actual = new URL(resource);
|
|
100
|
+
if (actual.href !== expected.href) {
|
|
101
|
+
throw new Error(`Unexpected OAuth resource URL. Expected ${expected.href}, received ${actual.href}.`);
|
|
102
|
+
}
|
|
103
|
+
return actual;
|
|
104
|
+
}
|
|
105
|
+
async saveDiscoveryState(state) {
|
|
106
|
+
await this.store.save((current) => ({
|
|
107
|
+
clientId: this.config.clientId,
|
|
108
|
+
codeVerifier: current?.codeVerifier,
|
|
109
|
+
discoveryState: state,
|
|
110
|
+
expectedState: current?.expectedState ?? this.pendingState,
|
|
111
|
+
gatewayUrl: this.config.gatewayUrl,
|
|
112
|
+
lastAuthenticatedAt: current?.lastAuthenticatedAt,
|
|
113
|
+
scope: this.config.scope,
|
|
114
|
+
tokens: current?.tokens,
|
|
115
|
+
updatedAt: new Date().toISOString(),
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
async discoveryState() {
|
|
119
|
+
const persistedState = (await this.store.load())?.discoveryState;
|
|
120
|
+
if (persistedState) {
|
|
121
|
+
return persistedState;
|
|
122
|
+
}
|
|
123
|
+
if (!this.config.oauthIssuerUrl) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
const issuer = new URL(this.config.oauthIssuerUrl);
|
|
127
|
+
const normalizedIssuer = trimTrailingSlash(issuer.href);
|
|
128
|
+
return {
|
|
129
|
+
authorizationServerMetadata: {
|
|
130
|
+
authorization_endpoint: new URL("/oauth/authorize", issuer).href,
|
|
131
|
+
code_challenge_methods_supported: ["S256"],
|
|
132
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
133
|
+
issuer: normalizedIssuer,
|
|
134
|
+
jwks_uri: new URL("/oauth/jwks.json", issuer).href,
|
|
135
|
+
response_modes_supported: ["query"],
|
|
136
|
+
response_types_supported: ["code"],
|
|
137
|
+
token_endpoint: new URL("/oauth/token", issuer).href,
|
|
138
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
139
|
+
},
|
|
140
|
+
authorizationServerUrl: issuer.href,
|
|
141
|
+
resourceMetadata: {
|
|
142
|
+
authorization_servers: [issuer.href],
|
|
143
|
+
resource: this.config.gatewayUrl,
|
|
144
|
+
resource_documentation: new URL("/settings", issuer).href,
|
|
145
|
+
resource_name: this.config.displayName,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
expectedState() {
|
|
150
|
+
return this.pendingState;
|
|
151
|
+
}
|
|
152
|
+
state() {
|
|
153
|
+
const state = randomUUID();
|
|
154
|
+
this.pendingState = state;
|
|
155
|
+
void this.store
|
|
156
|
+
.save((current) => ({
|
|
157
|
+
clientId: this.config.clientId,
|
|
158
|
+
codeVerifier: current?.codeVerifier,
|
|
159
|
+
discoveryState: current?.discoveryState,
|
|
160
|
+
expectedState: state,
|
|
161
|
+
gatewayUrl: this.config.gatewayUrl,
|
|
162
|
+
lastAuthenticatedAt: current?.lastAuthenticatedAt,
|
|
163
|
+
scope: this.config.scope,
|
|
164
|
+
tokens: current?.tokens,
|
|
165
|
+
updatedAt: new Date().toISOString(),
|
|
166
|
+
}))
|
|
167
|
+
.catch(() => undefined);
|
|
168
|
+
return state;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function buildHostConfigSnippet(config, serverName) {
|
|
2
|
+
return {
|
|
3
|
+
mcpServers: {
|
|
4
|
+
[serverName]: {
|
|
5
|
+
command: "npx",
|
|
6
|
+
args: ["-y", "@entergram/mcp", "serve"],
|
|
7
|
+
env: {
|
|
8
|
+
ENTERGRAM_MCP_ENV: config.environment,
|
|
9
|
+
ENTERGRAM_MCP_CLIENT_ID: config.clientId,
|
|
10
|
+
ENTERGRAM_MCP_SCOPE: config.scope,
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function tomlString(value) {
|
|
17
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`;
|
|
18
|
+
}
|
|
19
|
+
export function buildHostConfigToml(config, serverName) {
|
|
20
|
+
return [
|
|
21
|
+
`[mcp_servers.${serverName}]`,
|
|
22
|
+
`command = "npx"`,
|
|
23
|
+
`args = ["-y", "@entergram/mcp", "serve"]`,
|
|
24
|
+
"",
|
|
25
|
+
`[mcp_servers.${serverName}.env]`,
|
|
26
|
+
`ENTERGRAM_MCP_ENV = ${tomlString(config.environment)}`,
|
|
27
|
+
`ENTERGRAM_MCP_CLIENT_ID = ${tomlString(config.clientId)}`,
|
|
28
|
+
`ENTERGRAM_MCP_SCOPE = ${tomlString(config.scope)}`,
|
|
29
|
+
"",
|
|
30
|
+
].join("\n");
|
|
31
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { UnauthorizedError, } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
3
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
+
import { waitForOAuthCallback } from "./oauth-callback.js";
|
|
5
|
+
import { CLI_VERSION, ENTERGRAM_HOST_NAME } from "./constants.js";
|
|
6
|
+
export class EntergramRemoteClient {
|
|
7
|
+
config;
|
|
8
|
+
authProvider;
|
|
9
|
+
client;
|
|
10
|
+
transport;
|
|
11
|
+
constructor(config, authProvider) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
this.authProvider = authProvider;
|
|
14
|
+
}
|
|
15
|
+
get connectedClient() {
|
|
16
|
+
if (!this.client) {
|
|
17
|
+
throw new Error("Entergram remote client is not connected.");
|
|
18
|
+
}
|
|
19
|
+
return this.client;
|
|
20
|
+
}
|
|
21
|
+
async callTool(params) {
|
|
22
|
+
return this.withRetry((client) => client.callTool(params), true);
|
|
23
|
+
}
|
|
24
|
+
async close() {
|
|
25
|
+
await this.transport?.terminateSession().catch(() => undefined);
|
|
26
|
+
await this.client?.close().catch(() => undefined);
|
|
27
|
+
this.transport = undefined;
|
|
28
|
+
this.client = undefined;
|
|
29
|
+
}
|
|
30
|
+
async connect(options) {
|
|
31
|
+
await this.close();
|
|
32
|
+
const createPair = () => {
|
|
33
|
+
const client = new Client({
|
|
34
|
+
name: ENTERGRAM_HOST_NAME,
|
|
35
|
+
version: CLI_VERSION,
|
|
36
|
+
});
|
|
37
|
+
const transport = new StreamableHTTPClientTransport(new URL(this.config.gatewayUrl), {
|
|
38
|
+
authProvider: this.authProvider,
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
client,
|
|
42
|
+
transport,
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
let { client, transport } = createPair();
|
|
46
|
+
try {
|
|
47
|
+
await client.connect(transport);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (!(error instanceof UnauthorizedError) || !options.interactive) {
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
await client.close().catch(() => undefined);
|
|
54
|
+
({ client, transport } = createPair());
|
|
55
|
+
const callbackPromise = waitForOAuthCallback(this.config.callbackUrl, 5 * 60 * 1000, () => this.authProvider.expectedState());
|
|
56
|
+
try {
|
|
57
|
+
await client.connect(transport);
|
|
58
|
+
}
|
|
59
|
+
catch (interactiveError) {
|
|
60
|
+
if (!(interactiveError instanceof UnauthorizedError)) {
|
|
61
|
+
throw interactiveError;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const code = await callbackPromise;
|
|
65
|
+
await transport.finishAuth(code);
|
|
66
|
+
await client.connect(transport);
|
|
67
|
+
}
|
|
68
|
+
this.client = client;
|
|
69
|
+
this.transport = transport;
|
|
70
|
+
return client;
|
|
71
|
+
}
|
|
72
|
+
async ensureConnected(options) {
|
|
73
|
+
if (this.client) {
|
|
74
|
+
return this.client;
|
|
75
|
+
}
|
|
76
|
+
return this.connect(options);
|
|
77
|
+
}
|
|
78
|
+
async getPrompt(params) {
|
|
79
|
+
return this.withRetry((client) => client.getPrompt(params), true);
|
|
80
|
+
}
|
|
81
|
+
async listPrompts(params) {
|
|
82
|
+
return this.withRetry((client) => client.listPrompts(params), true);
|
|
83
|
+
}
|
|
84
|
+
async listResources(params) {
|
|
85
|
+
return this.withRetry((client) => client.listResources(params), true);
|
|
86
|
+
}
|
|
87
|
+
async listResourceTemplates(params) {
|
|
88
|
+
return this.withRetry((client) => client.listResourceTemplates(params), true);
|
|
89
|
+
}
|
|
90
|
+
async listTools(params) {
|
|
91
|
+
return this.withRetry((client) => client.listTools(params), true);
|
|
92
|
+
}
|
|
93
|
+
async readResource(params) {
|
|
94
|
+
return this.withRetry((client) => client.readResource(params), true);
|
|
95
|
+
}
|
|
96
|
+
async subscribeResource(params) {
|
|
97
|
+
return this.withRetry((client) => client.subscribeResource(params), true);
|
|
98
|
+
}
|
|
99
|
+
async unsubscribeResource(params) {
|
|
100
|
+
return this.withRetry((client) => client.unsubscribeResource(params), true);
|
|
101
|
+
}
|
|
102
|
+
async withRetry(callback, interactive) {
|
|
103
|
+
const client = await this.ensureConnected({ interactive });
|
|
104
|
+
try {
|
|
105
|
+
return await callback(client);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (!(error instanceof UnauthorizedError)) {
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
const refreshedClient = await this.connect({ interactive });
|
|
112
|
+
return callback(refreshedClient);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export class EntergramSessionStore {
|
|
4
|
+
sessionFilePath;
|
|
5
|
+
constructor(sessionFilePath) {
|
|
6
|
+
this.sessionFilePath = sessionFilePath;
|
|
7
|
+
}
|
|
8
|
+
async clear() {
|
|
9
|
+
try {
|
|
10
|
+
await fs.rm(this.sessionFilePath, { force: true });
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error.code !== "ENOENT") {
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async load() {
|
|
19
|
+
try {
|
|
20
|
+
const raw = await fs.readFile(this.sessionFilePath, "utf8");
|
|
21
|
+
return JSON.parse(raw);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
const err = error;
|
|
25
|
+
if (err.code === "ENOENT") {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async save(updater) {
|
|
32
|
+
const current = await this.load();
|
|
33
|
+
const next = updater(current);
|
|
34
|
+
await fs.mkdir(this.sessionDirectoryPath(), { recursive: true });
|
|
35
|
+
await fs.writeFile(this.sessionFilePath, `${JSON.stringify(next, null, 2)}\n`, {
|
|
36
|
+
encoding: "utf8",
|
|
37
|
+
mode: 0o600,
|
|
38
|
+
});
|
|
39
|
+
return next;
|
|
40
|
+
}
|
|
41
|
+
sessionDirectoryPath() {
|
|
42
|
+
return path.dirname(this.sessionFilePath);
|
|
43
|
+
}
|
|
44
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@entergram/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Official Entergram MCP CLI for local MCP hosts with OAuth login, stdio bridging, and host config helpers.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"entergram",
|
|
9
|
+
"mcp",
|
|
10
|
+
"model-context-protocol",
|
|
11
|
+
"oauth",
|
|
12
|
+
"claude-desktop",
|
|
13
|
+
"codex",
|
|
14
|
+
"cursor"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"entergram-mcp": "./dist/cli/bin.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist/cli",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc -p tsconfig.json",
|
|
31
|
+
"cli": "node dist/cli/bin.js",
|
|
32
|
+
"dev:cli": "tsx src/cli/bin.ts serve",
|
|
33
|
+
"pack:dry-run": "npm pack --dry-run",
|
|
34
|
+
"prepublishOnly": "npm run build && npm run typecheck",
|
|
35
|
+
"smoke:cli": "node dist/cli/bin.js help && node dist/cli/bin.js print-config --format toml --env dev --client-id entergram-smoke-client --name entergram-dev > /dev/null",
|
|
36
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "1.29.0",
|
|
40
|
+
"jose": "^6.1.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^24.6.0",
|
|
44
|
+
"tsx": "^4.20.6",
|
|
45
|
+
"typescript": "^5.9.3"
|
|
46
|
+
}
|
|
47
|
+
}
|