Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Added

- **MCP**: Add `organizationSlug` support to HTTP/OAuth transport [[#115], [6646b6e]]

### Fixed

- **MCP**: Fix HTTP transport organization slug fallback [[f0d5255]]
- **MCP**: Fix HTTP lint error in organization slug resolution [[87d9203]]

[#115]: https://github.com/studiometa/forge-tools/pull/115
[6646b6e]: https://github.com/studiometa/forge-tools/commit/6646b6e
[f0d5255]: https://github.com/studiometa/forge-tools/commit/f0d5255
[87d9203]: https://github.com/studiometa/forge-tools/commit/87d9203

## 0.4.2 - 2026.04.01

### Fixed
Expand Down
13 changes: 13 additions & 0 deletions packages/mcp/src/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ describe("parseAuthHeader", () => {
expect(result).toEqual({ apiToken: rawToken });
});

it("decodes a base64-encoded JSON payload with organization slug", () => {
const payload = { apiToken: "pk_test_abc123XYZ", organizationSlug: "studio-meta" };
const base64Token = Buffer.from(JSON.stringify(payload)).toString("base64");
const result = parseAuthHeader(`Bearer ${base64Token}`);
expect(result).toEqual(payload);
});

it("falls back to raw decoded token for non-credential JSON payloads", () => {
const invalidPayload = Buffer.from(JSON.stringify({ foo: "bar" })).toString("base64");
const result = parseAuthHeader(`Bearer ${invalidPayload}`);
expect(result).toEqual({ apiToken: JSON.stringify({ foo: "bar" }) });
});

it("keeps raw token when base64 roundtrip does not match", () => {
// A token that doesn't roundtrip as valid base64
const result = parseAuthHeader("Bearer not-valid-base64-token!");
Expand Down
7 changes: 1 addition & 6 deletions packages/mcp/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,6 @@ function tryDecodeBase64(token: string): string | null {
return null;
}

/**
* Try to parse a JSON credentials object from a decoded token.
* Returns the credentials if valid, null otherwise.
*/
function tryParseCredentials(decoded: string): ForgeCredentials | null {
try {
const parsed: unknown = JSON.parse(decoded);
Expand Down Expand Up @@ -95,12 +91,11 @@ export function parseAuthHeader(authHeader: string | undefined | null): ForgeCre
// Try to decode as base64 (OAuth access token)
const decoded = tryDecodeBase64(token);
if (decoded) {
// Try to parse as JSON credentials (new format with organizationSlug)
const credentials = tryParseCredentials(decoded);
if (credentials) {
return credentials;
}
// Fall back to treating decoded value as raw API token (legacy format)

return { apiToken: decoded };
}

Expand Down
2 changes: 2 additions & 0 deletions packages/mcp/src/crypto.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,15 @@ describe("crypto", () => {
it("creates and decodes an auth code with all fields", () => {
const credentials = {
apiToken: "pk_test",
organizationSlug: "studio-meta",
codeChallenge: "abc123",
codeChallengeMethod: "S256",
};
const code = createAuthCode(credentials);
const decoded = decodeAuthCode(code);

expect(decoded.apiToken).toBe("pk_test");
expect(decoded.organizationSlug).toBe("studio-meta");
expect(decoded.codeChallenge).toBe("abc123");
expect(decoded.codeChallengeMethod).toBe("S256");
});
Expand Down
172 changes: 166 additions & 6 deletions packages/mcp/src/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ import { toNodeHandler } from "h3/node";
import { createServer, type Server as HttpServer } from "node:http";
import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from "vitest";

const mockGetOrganizationSlug = vi.fn<() => string | null>(() => null);

vi.mock("@studiometa/forge-api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@studiometa/forge-api")>();
return {
...actual,
getOrganizationSlug: () => mockGetOrganizationSlug(),
};
});

// Mock the handlers module
vi.mock("./handlers/index.ts", () => ({
executeToolWithCredentials: vi
Expand Down Expand Up @@ -256,6 +266,11 @@ describe("Streamable HTTP MCP endpoint", () => {

const validToken = "test-forge-api-token-1234";

beforeEach(() => {
vi.clearAllMocks();
mockGetOrganizationSlug.mockReturnValue(null);
});

beforeAll(async () => {
const ctx = await createTestServer();
server = ctx.server;
Expand Down Expand Up @@ -523,7 +538,152 @@ describe("Streamable HTTP MCP endpoint", () => {
expect(executeToolWithCredentials).toHaveBeenCalledWith(
"forge",
{ resource: "servers", action: "list" },
{ apiToken: validToken },
{ apiToken: validToken, organizationSlug: undefined },
);
});

it("should fall back to configured organization slug when request args omit it", async () => {
mockGetOrganizationSlug.mockReturnValue("default-org");

const { sessionId } = await initializeSession(baseUrl, validToken);
await sendInitializedNotification(baseUrl, validToken, sessionId);

const response = await fetch(`${baseUrl}/mcp`, {
method: "POST",
headers: {
...MCP_HEADERS,
Authorization: `Bearer ${validToken}`,
"mcp-session-id": sessionId,
},
body: JSON.stringify({
jsonrpc: "2.0",
method: "tools/call",
params: {
name: "forge",
arguments: { resource: "servers", action: "list" },
},
id: 4,
}),
});

expect(response.ok).toBe(true);
await response.text();

expect(executeToolWithCredentials).toHaveBeenCalledWith(
"forge",
{ resource: "servers", action: "list" },
{ apiToken: validToken, organizationSlug: "default-org" },
);
});

it("should prefer organization slug embedded in OAuth credentials over configured default", async () => {
mockGetOrganizationSlug.mockReturnValue("default-org");
const oauthToken = Buffer.from(
JSON.stringify({ apiToken: validToken, organizationSlug: "oauth-org" }),
).toString("base64");

const { sessionId } = await initializeSession(baseUrl, oauthToken);
await sendInitializedNotification(baseUrl, oauthToken, sessionId);

const response = await fetch(`${baseUrl}/mcp`, {
method: "POST",
headers: {
...MCP_HEADERS,
Authorization: `Bearer ${oauthToken}`,
"mcp-session-id": sessionId,
},
body: JSON.stringify({
jsonrpc: "2.0",
method: "tools/call",
params: {
name: "forge",
arguments: { resource: "servers", action: "list" },
},
id: 41,
}),
});

expect(response.ok).toBe(true);
await response.text();

expect(executeToolWithCredentials).toHaveBeenCalledWith(
"forge",
{ resource: "servers", action: "list" },
{ apiToken: validToken, organizationSlug: "oauth-org" },
);
});

it("should prefer request organization slug over configured default", async () => {
mockGetOrganizationSlug.mockReturnValue("default-org");

const { sessionId } = await initializeSession(baseUrl, validToken);
await sendInitializedNotification(baseUrl, validToken, sessionId);

const response = await fetch(`${baseUrl}/mcp`, {
method: "POST",
headers: {
...MCP_HEADERS,
Authorization: `Bearer ${validToken}`,
"mcp-session-id": sessionId,
},
body: JSON.stringify({
jsonrpc: "2.0",
method: "tools/call",
params: {
name: "forge",
arguments: {
resource: "servers",
action: "list",
organizationSlug: "request-org",
},
},
id: 5,
}),
});

expect(response.ok).toBe(true);
await response.text();

expect(executeToolWithCredentials).toHaveBeenCalledWith(
"forge",
{ resource: "servers", action: "list", organizationSlug: "request-org" },
{ apiToken: validToken, organizationSlug: "request-org" },
);
});

it("should use organization slug from OAuth bearer credentials", async () => {
const oauthToken = Buffer.from(
JSON.stringify({ apiToken: validToken, organizationSlug: "oauth-org" }),
).toString("base64");

const { sessionId } = await initializeSession(baseUrl, oauthToken);
await sendInitializedNotification(baseUrl, oauthToken, sessionId);

const response = await fetch(`${baseUrl}/mcp`, {
method: "POST",
headers: {
...MCP_HEADERS,
Authorization: `Bearer ${oauthToken}`,
"mcp-session-id": sessionId,
},
body: JSON.stringify({
jsonrpc: "2.0",
method: "tools/call",
params: {
name: "forge",
arguments: { resource: "servers", action: "list" },
},
id: 51,
}),
});

expect(response.ok).toBe(true);
await response.text();

expect(executeToolWithCredentials).toHaveBeenCalledWith(
"forge",
{ resource: "servers", action: "list" },
{ apiToken: validToken, organizationSlug: "oauth-org" },
);
});

Expand All @@ -545,15 +705,15 @@ describe("Streamable HTTP MCP endpoint", () => {
name: "failing_tool",
arguments: {},
},
id: 4,
id: 6,
}),
});

expect(response.ok).toBe(true);
const text = await response.text();
const messages = parseSSEMessages(text);

const callResponse = messages.find((m) => m.id === 4);
const callResponse = messages.find((m) => m.id === 6);
expect(callResponse).toBeDefined();

const result = callResponse!.result as Record<string, unknown>;
Expand Down Expand Up @@ -627,12 +787,12 @@ describe("Streamable HTTP MCP endpoint", () => {
expect(executeToolWithCredentials).toHaveBeenCalledWith(
"forge",
{ resource: "servers", action: "list" },
{ apiToken: tokenA },
{ apiToken: tokenA, organizationSlug: undefined },
);
expect(executeToolWithCredentials).toHaveBeenCalledWith(
"forge",
{ resource: "sites", action: "list" },
{ apiToken: tokenB },
{ apiToken: tokenB, organizationSlug: undefined },
);
});
});
Expand Down Expand Up @@ -718,7 +878,7 @@ describe("Full server integration", () => {
expect(executeToolWithCredentials).toHaveBeenCalledWith(
"forge",
{ resource: "user", action: "get" },
{ apiToken: validToken },
{ apiToken: validToken, organizationSlug: undefined },
);
});
});
Expand Down
20 changes: 11 additions & 9 deletions packages/mcp/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
ListToolsRequestSchema,
type CallToolResult,
} from "@modelcontextprotocol/sdk/types.js";
import { getOrganizationSlug } from "@studiometa/forge-api";
import { H3, defineEventHandler } from "h3";

import { parseAuthHeader } from "./auth.ts";
Expand Down Expand Up @@ -55,7 +56,8 @@ export interface HttpServerOptions {
* Create a configured MCP Server instance for HTTP transport.
*
* Unlike stdio, HTTP mode does NOT include forge_configure/forge_get_config
* because credentials come from the Authorization header per-request.
* because API tokens come from the Authorization header per-request.
* Organization slug still falls back to FORGE_ORG/config when omitted.
*/
export function createMcpServer(options?: HttpServerOptions): Server {
const readOnly = options?.readOnly ?? false;
Expand All @@ -80,7 +82,10 @@ export function createMcpServer(options?: HttpServerOptions): Server {

server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
const { name, arguments: args } = request.params;
const token = extra.authInfo?.token;
const authInfo = extra.authInfo as { token?: string; organizationSlug?: string } | undefined;
const token = authInfo?.token;
const organizationSlugFromAuth =
typeof authInfo?.organizationSlug === "string" ? authInfo.organizationSlug : undefined;

/* v8 ignore start */
if (!token) {
Expand Down Expand Up @@ -121,18 +126,14 @@ export function createMcpServer(options?: HttpServerOptions): Server {
// Organization slug can come from:
// 1. Tool args (per-request override, highest priority)
// 2. Auth token (configured during OAuth flow)
// 3. FORGE_ORG / config fallback
/* v8 ignore next 2 -- defensive type guard */
const orgSlugFromArgs =
typeof args?.organizationSlug === "string" ? args.organizationSlug : undefined;
/* v8 ignore next 3 -- defensive type guard */
const orgSlugFromAuth =
typeof extra.authInfo?.extra?.organizationSlug === "string"
? extra.authInfo.extra.organizationSlug
: undefined;

const result = await executeToolWithCredentials(name, /* v8 ignore next */ args ?? {}, {
apiToken: token,
organizationSlug: orgSlugFromArgs ?? orgSlugFromAuth,
organizationSlug:
orgSlugFromArgs ?? organizationSlugFromAuth ?? getOrganizationSlug() ?? undefined,
});
return result as CallToolResult;
} catch (error) {
Expand Down Expand Up @@ -201,6 +202,7 @@ export async function handleMcpRequest(
Object.assign(req, {
auth: {
token: credentials.apiToken,
organizationSlug: credentials.organizationSlug,
clientId: "forge-http-client",
scopes: [],
extra: {
Expand Down
Loading
Loading