@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 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
+ ```
@@ -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
+ }
@@ -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
+ }