Skip to content
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,17 @@ claude mcp add --transport sse --scope user absmartly \
-H "Authorization:my-subdomain YOUR_API_KEY"
```

#### Remote with API Key (Streamable HTTP — recommended for new installs)

```bash
claude mcp add --transport http --scope user absmartly \
https://mcp.absmartly.com/mcp \
-H "Authorization:YOUR_API_KEY" \
-H "x-absmartly-endpoint:https://your-instance.absmartly.com"
```

> Streamable HTTP is the modern MCP transport (the MCP spec deprecated SSE in March 2025). The `/sse` URL still works for older clients.

#### Remote with OAuth

```bash
Expand Down Expand Up @@ -181,6 +192,23 @@ Click the badge above to install with one click (OAuth). After Cursor prompts yo
}
```

**Or with Streamable HTTP transport (modern, recommended):**

```json
{
"mcpServers": {
"absmartly": {
"type": "http",
"url": "https://mcp.absmartly.com/mcp",
"headers": {
"Authorization": "YOUR_API_KEY",
"x-absmartly-endpoint": "https://your-instance.absmartly.com"
}
}
}
}
```

#### With OAuth

```json
Expand Down Expand Up @@ -230,6 +258,22 @@ Cursor will detect the OAuth requirement and open your browser for login. The `a

> Note: Windsurf uses `"serverUrl"` instead of `"url"`.

**Or with Streamable HTTP transport:**

```json
{
"mcpServers": {
"absmartly": {
"serverUrl": "https://mcp.absmartly.com/mcp",
"headers": {
"Authorization": "YOUR_API_KEY",
"x-absmartly-endpoint": "https://your-instance.absmartly.com"
}
}
}
}
```

#### With OAuth

```json
Expand Down Expand Up @@ -291,6 +335,31 @@ Click a badge above for one-click install (OAuth). You'll be prompted for your A
}
```

**Or with Streamable HTTP transport (recommended):**

```json
{
"servers": {
"absmartly": {
"type": "http",
"url": "https://mcp.absmartly.com/mcp",
"headers": {
"Authorization": "${input:absmartly-api-key}",
"x-absmartly-endpoint": "https://your-instance.absmartly.com"
}
}
},
"inputs": [
{
"type": "promptString",
"id": "absmartly-api-key",
"description": "ABsmartly API Key",
"password": true
}
]
}
```

#### With OAuth

```json
Expand Down Expand Up @@ -364,6 +433,18 @@ gemini mcp add --transport sse --scope user absmartly \
-H "x-absmartly-endpoint: https://your-instance.absmartly.com"
```

#### Gemini Enterprise (Google Cloud Console)

Gemini Enterprise's **Custom MCP Server** connector (Preview) requires Streamable HTTP transport — use the `/mcp` endpoint:

1. Google Cloud Console → **Gemini Enterprise** → **Data stores** → **Create data store**.
2. Search "Custom MCP Server" → **Add MCP server**.
3. **Server URL:** `https://mcp.absmartly.com/mcp`
4. **Authentication:** OAuth — register Gemini Enterprise as an OAuth client against your identity provider, then provide the `client_id` / `client_secret`. Grant the `mcp:access` scope.
5. Save and wait for the connector status to become **Active**.

> SSE transport (`/sse`) is **not** supported by this connector. The legacy Gemini CLI / Code Assist sections above continue to use `/sse` until those clients add Streamable HTTP support.

> Reload the IDE window after editing settings (VS Code: Command Palette → **Developer: Reload Window**). MCP support in Code Assist requires **agent preview mode** — set `"geminicodeassist.updateChannel": "Insiders"` in VS Code settings if not already enabled.
Comment on lines +446 to 448
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix markdownlint MD028: remove the blank line inside the blockquote.

There is an empty line between two quoted lines, which triggers no-blanks-blockquote. Keep both quoted lines contiguous (or make the second one a normal paragraph).

Suggested fix
 > SSE transport (`/sse`) is **not** supported by this connector. The legacy Gemini CLI / Code Assist sections above continue to use `/sse` until those clients add Streamable HTTP support.
-
 > Reload the IDE window after editing settings (VS Code: Command Palette → **Developer: Reload Window**). MCP support in Code Assist requires **agent preview mode** — set `"geminicodeassist.updateChannel": "Insiders"` in VS Code settings if not already enabled.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
> SSE transport (`/sse`) is **not** supported by this connector. The legacy Gemini CLI / Code Assist sections above continue to use `/sse` until those clients add Streamable HTTP support.
> Reload the IDE window after editing settings (VS Code: Command Palette → **Developer: Reload Window**). MCP support in Code Assist requires **agent preview mode** — set `"geminicodeassist.updateChannel": "Insiders"` in VS Code settings if not already enabled.
> SSE transport (`/sse`) is **not** supported by this connector. The legacy Gemini CLI / Code Assist sections above continue to use `/sse` until those clients add Streamable HTTP support.
> Reload the IDE window after editing settings (VS Code: Command Palette → **Developer: Reload Window**). MCP support in Code Assist requires **agent preview mode** — set `"geminicodeassist.updateChannel": "Insiders"` in VS Code settings if not already enabled.
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 447-447: Blank line inside blockquote

(MD028, no-blanks-blockquote)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 446 - 448, The blockquote contains an undesired blank
line causing markdownlint MD028; remove the empty line between the two quoted
lines so the two `>` lines are contiguous (or convert the second quoted line
into a normal paragraph). Edit the block that currently has the two separate `>`
lines (the SSE transport note and the Reload the IDE window note) to be adjacent
`>` lines with no blank line between them so the blockquote is continuous.


### Option 7: ChatGPT (Developer Mode)
Expand Down
213 changes: 129 additions & 84 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { Env } from "./types";
import { debug } from "./config";
import { MCP_VERSION } from "./version";
import { DXT_BUNDLE_BASE64, DXT_BUNDLE_SHA } from "./dxt-bundle";

Check failure on line 11 in src/index.ts

View workflow job for this annotation

GitHub Actions / test

Cannot find module './dxt-bundle' or its corresponding type declarations.

Check failure on line 11 in src/index.ts

View workflow job for this annotation

GitHub Actions / test

Cannot find module './dxt-bundle' or its corresponding type declarations.
import { APIClient } from "@absmartly/cli/api-client";
import type { CustomSectionField } from "@absmartly/cli/api-client";
import { FetchHttpClient } from "./fetch-adapter";
Expand All @@ -32,6 +32,12 @@
safeKvGet,
} from "./shared";

const MCP_CORS_OPTIONS = {
origin: "*",
methods: "GET, POST, OPTIONS",
headers: "Content-Type, Authorization, Accept",
} as const;

export class ABsmartlyMCP extends McpAgent<Env, Record<string, never>, ABsmartlyProps> {
server = new McpServer(
{
Expand Down Expand Up @@ -535,13 +541,20 @@
}
}

const baseMcpHandler = ABsmartlyMCP.mount("/sse");
const sseMcpHandler = ABsmartlyMCP.serveSSE("/sse", {
corsOptions: MCP_CORS_OPTIONS
});

const streamableMcpHandler = ABsmartlyMCP.serve("/mcp", {
corsOptions: MCP_CORS_OPTIONS
});

const oauthHandler = new ABsmartlyOAuthHandler();

const oauthProvider = new OAuthProvider({
apiHandlers: {
"/sse": baseMcpHandler
"/sse": sseMcpHandler,
"/mcp": streamableMcpHandler
},
authorizeEndpoint: "/authorize",
tokenEndpoint: "/token",
Expand Down Expand Up @@ -591,6 +604,106 @@
},
} as any);

type McpTransportRoute = {
pathPrefix: string;
handler: { fetch: (request: Request, env: any, ctx: any) => Promise<Response> };
};

async function handleMcpTransportRequest(
request: Request,
env: any,
ctx: any,
route: McpTransportRoute,
detected: { apiKey: string | null; endpoint: string | null },
clientFingerprint: string
): Promise<Response> {
const url = new URL(request.url);
const { apiKey, endpoint } = detected;

if (apiKey) {
debug(`API key detected on ${route.pathPrefix}, bypassing OAuth flow`);

try {
const verifyResult = await verifyApiKey(apiKey, endpoint || DEFAULT_ABSMARTLY_ENDPOINT);

if (!verifyResult.ok) {
const isTransient = verifyResult.error === 'server_error' || verifyResult.error === 'network_error';
console.error(`Failed to verify API key: ${verifyResult.error}`);
return new Response(isTransient ? "ABsmartly service temporarily unavailable" : "Unauthorized", {
status: isTransient ? 503 : 401,
headers: CORS_HEADERS,
});
}

const userData = verifyResult.user;
const userId = userData.id?.toString() || userData.email;

if (!userData.email) {
debug('No email found in API response for API key authentication, user data:', userData);
}

const props: ABsmartlyProps = {
email: userData.email || DEFAULT_API_KEY_USER_EMAIL,
name: `${userData.first_name || ''} ${userData.last_name || ''}`.trim() || userData.email || DEFAULT_API_KEY_USER_NAME,
absmartly_endpoint: endpoint || DEFAULT_ABSMARTLY_ENDPOINT,
absmartly_api_key: apiKey,
user_id: userId
};

debug(`API key authenticated for user: ${props.email}`);

const session = {
userId,
email: props.email,
name: props.name,
absmartly_endpoint: props.absmartly_endpoint,
absmartly_api_key: apiKey,
createdAt: Date.now(),
expiresAt: Date.now() + (SESSION_TTL_SECONDS * 1000)
};

await safeKvPut(env.OAUTH_KV, `session:${userId}`, JSON.stringify(session), {
expirationTtl: SESSION_TTL_SECONDS,
});

ctx.props = props;
return await route.handler.fetch(request, env, ctx);
} catch (error) {
console.error("Error during API key authentication:", error);
return new Response("Internal Server Error", {
status: 500,
headers: CORS_HEADERS,
});
}
}

const authHeader = request.headers.get("Authorization");

if (!authHeader || !authHeader.startsWith("Bearer ")) {
debug(`No valid Authorization header on ${route.pathPrefix}, returning 401 to trigger OAuth flow`);

const requestedEndpoint = url.searchParams.get('absmartly-endpoint') ||
request.headers.get('x-absmartly-endpoint') ||
extractEndpointFromPath(url.pathname, route.pathPrefix) ||
endpoint;
if (requestedEndpoint) {
await safeKvPut(env.OAUTH_KV, `oauth_endpoint_pending:${clientFingerprint}`, requestedEndpoint, {
expirationTtl: OAUTH_STATE_TTL_SECONDS,
});
}

return new Response("Unauthorized", {
status: 401,
headers: {
...CORS_HEADERS,
"WWW-Authenticate": 'Bearer realm="OAuth"',
},
});
}

return await oauthProvider.fetch(request, env, ctx);
}

export default {
async fetch(request: Request, env: any, ctx: any): Promise<Response> {
const url = new URL(request.url);
Expand Down Expand Up @@ -660,89 +773,21 @@
}

if (url.pathname.startsWith("/sse")) {
if (apiKey) {
debug("API key detected, bypassing OAuth flow");

try {
const verifyResult = await verifyApiKey(apiKey, endpoint || DEFAULT_ABSMARTLY_ENDPOINT);

if (!verifyResult.ok) {
const isTransient = verifyResult.error === 'server_error' || verifyResult.error === 'network_error';
console.error(`Failed to verify API key: ${verifyResult.error}`);
return new Response(isTransient ? "ABsmartly service temporarily unavailable" : "Unauthorized", {
status: isTransient ? 503 : 401,
headers: CORS_HEADERS,
});
}

const userData = verifyResult.user;
const userId = userData.id?.toString() || userData.email;

if (!userData.email) {
debug('No email found in API response for API key authentication, user data:', userData);
}

const props: ABsmartlyProps = {
email: userData.email || DEFAULT_API_KEY_USER_EMAIL,
name: `${userData.first_name || ''} ${userData.last_name || ''}`.trim() || userData.email || DEFAULT_API_KEY_USER_NAME,
absmartly_endpoint: endpoint || DEFAULT_ABSMARTLY_ENDPOINT,
absmartly_api_key: apiKey,
user_id: userId
};

debug(`API key authenticated for user: ${props.email}`);

const session = {
userId: userId,
email: props.email,
name: props.name,
absmartly_endpoint: props.absmartly_endpoint,
absmartly_api_key: apiKey,
createdAt: Date.now(),
expiresAt: Date.now() + (SESSION_TTL_SECONDS * 1000)
};

await safeKvPut(env.OAUTH_KV, `session:${userId}`, JSON.stringify(session), {
expirationTtl: SESSION_TTL_SECONDS,
});

ctx.props = props;
return await baseMcpHandler.fetch(request, env, ctx);

} catch (error) {
console.error("Error during API key authentication:", error);
return new Response("Internal Server Error", {
status: 500,
headers: CORS_HEADERS,
});
}
}

const authHeader = request.headers.get("Authorization");

if (!authHeader || !authHeader.startsWith("Bearer ")) {
debug("No valid Authorization header, returning 401 to trigger OAuth flow");

const requestedEndpoint = url.searchParams.get('absmartly-endpoint') ||
request.headers.get('x-absmartly-endpoint') ||
extractEndpointFromPath(url.pathname, '/sse') ||
endpoint;
if (requestedEndpoint) {
await safeKvPut(env.OAUTH_KV, `oauth_endpoint_pending:${clientFingerprint}`, requestedEndpoint, {
expirationTtl: OAUTH_STATE_TTL_SECONDS,
});
}

return new Response("Unauthorized", {
status: 401,
headers: {
...CORS_HEADERS,
"WWW-Authenticate": 'Bearer realm="OAuth"',
},
});
}
return await handleMcpTransportRequest(
request, env, ctx,
{ pathPrefix: "/sse", handler: sseMcpHandler },
{ apiKey, endpoint },
clientFingerprint
);
}

return await oauthProvider.fetch(request, env, ctx);
if (url.pathname.startsWith("/mcp")) {
return await handleMcpTransportRequest(
request, env, ctx,
{ pathPrefix: "/mcp", handler: streamableMcpHandler },
{ apiKey, endpoint },
Comment on lines +778 to +788
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Replace inline transport path literals with top-level constants (Line 778 and Line 787).

"/sse" and "/mcp" are hardcoded in the route objects. Move these to grouped ALL_CAPS constants and reuse them across handler registration and dispatch to avoid drift.

As per coding guidelines, "Never use magic strings or hardcoded values inline in code - all default values must be declared as constants at the top of the file".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/index.ts` around lines 778 - 788, The code uses hardcoded path literals
"/sse" and "/mcp" when registering handlers and dispatching (see pathPrefix
properties passed to handleMcpTransportRequest and the url.pathname.startsWith
checks); define top-level ALL_CAPS constants (e.g., const SSE_PATH = "/sse";
const MCP_PATH = "/mcp";) near other defaults and replace all inline occurrences
with those constants so sseMcpHandler, streamableMcpHandler,
handleMcpTransportRequest and any url.pathname.startsWith checks reference
SSE_PATH and MCP_PATH instead of string literals to prevent drift.

clientFingerprint
);
}

if (url.pathname === '/register' && request.method === 'POST') {
Expand Down
18 changes: 11 additions & 7 deletions src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,16 @@ export function buildAuthHeader(authToken: string, isApiKey: boolean): Record<st
};
}

export function extractEndpointFromPath(pathname: string, prefix: string): string | null {
if (!pathname.startsWith(prefix + '/')) return null;
const hostPart = pathname.slice(prefix.length + 1).replace(/\/+$/, '');
if (!hostPart) return null;
const host = hostPart.includes('.') ? hostPart : `${hostPart}.${DEFAULT_ABSMARTLY_DOMAIN}`;
return `https://${host}`;
export function extractEndpointFromPath(pathname: string, prefix: string | readonly string[]): string | null {
const prefixes = Array.isArray(prefix) ? prefix : [prefix];
for (const p of prefixes) {
if (!pathname.startsWith(p + '/')) continue;
const hostPart = pathname.slice(p.length + 1).replace(/\/+$/, '');
if (!hostPart) continue;
const host = hostPart.includes('.') ? hostPart : `${hostPart}.${DEFAULT_ABSMARTLY_DOMAIN}`;
return `https://${host}`;
}
return null;
}

export function pickDefined(source: Record<string, unknown>, keys: string[]): Record<string, unknown> {
Expand Down Expand Up @@ -99,7 +103,7 @@ export function detectApiKey(
const url = new URL(request.url);
const authHeader = request.headers.get("Authorization");

const endpointFromPath = extractEndpointFromPath(url.pathname, '/sse');
const endpointFromPath = extractEndpointFromPath(url.pathname, ['/sse', '/mcp']);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Extract transport prefixes into a top-level constant (Line 106).

Using ['/sse', '/mcp'] inline introduces a magic value in a changed path. Please promote this into an ALL_CAPS constant near the other shared constants and reuse it here.

As per coding guidelines, "Never use magic strings or hardcoded values inline in code - all default values must be declared as constants at the top of the file".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/shared.ts` at line 106, Promote the inline array ['/sse', '/mcp'] into a
top-level ALL_CAPS constant (e.g., TRANSPORT_PREFIXES) declared with the other
shared constants, and replace the inline usage in the extractEndpointFromPath
call so that endpointFromPath is produced by
extractEndpointFromPath(url.pathname, TRANSPORT_PREFIXES); update any
imports/exports if needed to keep naming consistent.


const apiKeyFromQuery = url.searchParams.get("api_key") || url.searchParams.get("apikey");
if (apiKeyFromQuery) {
Expand Down
Loading
Loading