diff --git a/CHANGELOG.md b/CHANGELOG.md index 91f724b..b121bb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/mcp/src/auth.test.ts b/packages/mcp/src/auth.test.ts index 312d1cd..4f420c4 100644 --- a/packages/mcp/src/auth.test.ts +++ b/packages/mcp/src/auth.test.ts @@ -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!"); diff --git a/packages/mcp/src/auth.ts b/packages/mcp/src/auth.ts index df6f8ff..3fb412e 100644 --- a/packages/mcp/src/auth.ts +++ b/packages/mcp/src/auth.ts @@ -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); @@ -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 }; } diff --git a/packages/mcp/src/crypto.test.ts b/packages/mcp/src/crypto.test.ts index f339ec8..7ad15bc 100644 --- a/packages/mcp/src/crypto.test.ts +++ b/packages/mcp/src/crypto.test.ts @@ -88,6 +88,7 @@ describe("crypto", () => { it("creates and decodes an auth code with all fields", () => { const credentials = { apiToken: "pk_test", + organizationSlug: "studio-meta", codeChallenge: "abc123", codeChallengeMethod: "S256", }; @@ -95,6 +96,7 @@ describe("crypto", () => { 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"); }); diff --git a/packages/mcp/src/http.test.ts b/packages/mcp/src/http.test.ts index 14b4fbc..7d35052 100644 --- a/packages/mcp/src/http.test.ts +++ b/packages/mcp/src/http.test.ts @@ -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(); + return { + ...actual, + getOrganizationSlug: () => mockGetOrganizationSlug(), + }; +}); + // Mock the handlers module vi.mock("./handlers/index.ts", () => ({ executeToolWithCredentials: vi @@ -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; @@ -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" }, ); }); @@ -545,7 +705,7 @@ describe("Streamable HTTP MCP endpoint", () => { name: "failing_tool", arguments: {}, }, - id: 4, + id: 6, }), }); @@ -553,7 +713,7 @@ describe("Streamable HTTP MCP endpoint", () => { 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; @@ -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 }, ); }); }); @@ -718,7 +878,7 @@ describe("Full server integration", () => { expect(executeToolWithCredentials).toHaveBeenCalledWith( "forge", { resource: "user", action: "get" }, - { apiToken: validToken }, + { apiToken: validToken, organizationSlug: undefined }, ); }); }); diff --git a/packages/mcp/src/http.ts b/packages/mcp/src/http.ts index 0fd1a44..e584351 100644 --- a/packages/mcp/src/http.ts +++ b/packages/mcp/src/http.ts @@ -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"; @@ -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; @@ -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) { @@ -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) { @@ -201,6 +202,7 @@ export async function handleMcpRequest( Object.assign(req, { auth: { token: credentials.apiToken, + organizationSlug: credentials.organizationSlug, clientId: "forge-http-client", scopes: [], extra: { diff --git a/packages/mcp/src/oauth.test.ts b/packages/mcp/src/oauth.test.ts index 62d5ccc..e4e1207 100644 --- a/packages/mcp/src/oauth.test.ts +++ b/packages/mcp/src/oauth.test.ts @@ -5,6 +5,7 @@ import { createServer, type Server } from "node:http"; import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { decodeAuthCode } from "./crypto.ts"; +import { parseAuthHeader } from "./auth.ts"; import { oauthMetadataHandler, protectedResourceHandler, @@ -80,10 +81,10 @@ describe("OAuth endpoints", () => { // --- Utility function tests --- describe("createAccessToken", () => { - it("creates a base64-encoded JSON token", () => { + it("creates a base64-encoded token", () => { const token = createAccessToken("my-forge-token"); const decoded = Buffer.from(token, "base64").toString("utf-8"); - expect(JSON.parse(decoded)).toEqual({ apiToken: "my-forge-token" }); + expect(decoded).toBe("my-forge-token"); }); it("includes organizationSlug when provided", () => { @@ -94,6 +95,12 @@ describe("OAuth endpoints", () => { organizationSlug: "my-org", }); }); + + it("creates a base64-encoded JSON payload when organization slug is provided", () => { + const token = createAccessToken("my-forge-token", "studio-meta"); + const decoded = JSON.parse(Buffer.from(token, "base64").toString("utf-8")); + expect(decoded).toEqual({ apiToken: "my-forge-token", organizationSlug: "studio-meta" }); + }); }); describe("createS256Challenge", () => { @@ -207,6 +214,8 @@ describe("OAuth endpoints", () => { const html = await response.text(); expect(html).toContain("Connect to Laravel Forge"); expect(html).toContain("Forge API Token"); + expect(html).toContain("Organization Slug"); + expect(html).toContain('name="organizationSlug"'); expect(html).toContain("abc123"); // state in hidden field expect(html).toContain(codeChallenge); // code_challenge in hidden field expect(html).toContain("not stored on this server"); // privacy notice @@ -286,18 +295,43 @@ describe("OAuth endpoints", () => { expect(decoded.codeChallenge).toBe(codeChallenge); }); + it("preserves organization slug in the authorization code", async () => { + const { codeChallenge } = generatePKCE(); + const response = await fetch(`${baseUrl}/authorize`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + apiToken: "pk_test123", + organizationSlug: "studio-meta", + redirectUri: "https://example.com/callback", + codeChallenge, + codeChallengeMethod: "S256", + }).toString(), + }); + + expect(response.status).toBe(200); + const html = await response.text(); + const code = new URL(extractRedirectUrl(html)).searchParams.get("code")!; + const decoded = decodeAuthCode(code); + + expect(decoded.organizationSlug).toBe("studio-meta"); + }); + it("shows error when API token is missing", async () => { const response = await fetch(`${baseUrl}/authorize`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ redirectUri: "https://example.com/callback", + organizationSlug: "studio-meta", }).toString(), }); expect(response.ok).toBe(true); const html = await response.text(); expect(html).toContain("Forge API Token is required"); + expect(html).toContain('name="organizationSlug"'); + expect(html).toContain('value="studio-meta"'); }); it("shows error when redirectUri is missing in POST", async () => { @@ -418,9 +452,47 @@ describe("OAuth endpoints", () => { refresh_token: expect.any(String), }); - // Verify the access token contains our API token (base64-encoded JSON) + // Verify the access token contains our API token (base64 encoded) const decoded = Buffer.from(tokenData.access_token, "base64").toString("utf-8"); - expect(JSON.parse(decoded)).toEqual({ apiToken: "pk_test123" }); + expect(decoded).toBe("pk_test123"); + }); + + it("embeds organization slug in the access token and refresh token", async () => { + const { codeVerifier, codeChallenge } = generatePKCE(); + + const authResponse = await fetch(`${baseUrl}/authorize`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + apiToken: "pk_test123", + organizationSlug: "studio-meta", + redirectUri: "https://example.com/callback", + codeChallenge, + codeChallengeMethod: "S256", + }).toString(), + }); + + const html = await authResponse.text(); + const code = new URL(extractRedirectUrl(html)).searchParams.get("code")!; + + const tokenResponse = await fetch(`${baseUrl}/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + code_verifier: codeVerifier, + }).toString(), + }); + + expect(tokenResponse.ok).toBe(true); + const tokenData = await tokenResponse.json(); + + expect(parseAuthHeader(`Bearer ${tokenData.access_token}`)).toEqual({ + apiToken: "pk_test123", + organizationSlug: "studio-meta", + }); + expect(decodeAuthCode(tokenData.refresh_token).organizationSlug).toBe("studio-meta"); }); it("rejects invalid PKCE code_verifier", async () => { diff --git a/packages/mcp/src/oauth.ts b/packages/mcp/src/oauth.ts index 39c0bb6..6a57dc2 100644 --- a/packages/mcp/src/oauth.ts +++ b/packages/mcp/src/oauth.ts @@ -8,7 +8,7 @@ * * Flow: * 1. Claude redirects user to /authorize with OAuth params (including PKCE) - * 2. User enters their Forge API token in a login form + * 2. User enters their Forge API token and optional organization slug in a login form * 3. Server encrypts the token + PKCE challenge into an authorization code * 4. Redirects back to Claude with the code * 5. Claude exchanges code for access token via /token (with code_verifier) @@ -22,15 +22,14 @@ import { createAuthCode, decodeAuthCode } from "./crypto.ts"; /** * Create a base64-encoded access token from Forge credentials. - * The access token is base64(JSON.stringify({apiToken, organizationSlug})) so that - * parseAuthHeader can decode it on every request without any server-side lookup. + * + * Backward compatibility: + * - without organizationSlug: base64(apiToken) + * - with organizationSlug: base64(JSON.stringify({ apiToken, organizationSlug })) */ export function createAccessToken(apiToken: string, organizationSlug?: string): string { - const payload: { apiToken: string; organizationSlug?: string } = { apiToken }; - if (organizationSlug) { - payload.organizationSlug = organizationSlug; - } - return Buffer.from(JSON.stringify(payload)).toString("base64"); + const payload = organizationSlug ? JSON.stringify({ apiToken, organizationSlug }) : apiToken; + return Buffer.from(payload).toString("base64"); } /** @@ -221,6 +220,7 @@ export const authorizePostHandler = defineEventHandler(async (event: H3Event) => state, codeChallenge, codeChallengeMethod, + organizationSlug, error: "Forge API Token is required", }); } @@ -307,7 +307,7 @@ export const tokenHandler = defineEventHandler(async (event: H3Event) => { } } - // Create access token: base64(JSON.stringify({apiToken, organizationSlug})) — decodable on every request + // Create access token with embedded credentials when organizationSlug is present. const accessToken = createAccessToken(payload.apiToken, payload.organizationSlug); // Create refresh token (encrypted credentials, longer expiry) @@ -399,9 +399,11 @@ function renderLoginForm(params: { state?: string; codeChallenge?: string; codeChallengeMethod?: string; + organizationSlug?: string; error?: string; }): string { - const { redirectUri, state, codeChallenge, codeChallengeMethod, error } = params; + const { redirectUri, state, codeChallenge, codeChallengeMethod, organizationSlug, error } = + params; return ` @@ -554,10 +556,8 @@ function renderLoginForm(params: {
- -

- Your organization's slug from the Forge dashboard URL. Can also be overridden per-request. -

+ +

Optional, but recommended as the default org for Claude MCP requests.