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
6 changes: 6 additions & 0 deletions apps/api/alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,12 @@ export const createMapleApi = ({ stage, domains, replayBlobs }: CreateMapleApiOp
namespaceId: 2026072102,
simple: { limit: 60, period: 60 },
}),
// Authenticated POST /mcp, per credential. A short window so a runaway
// agent loop is cut off in seconds, at twice the v2 API's throughput.
MCP_TOOLS_RATE_LIMITER: Cloudflare.RateLimit("MCP_TOOLS_RATE_LIMITER", {
namespaceId: 2026082901,
simple: { limit: 120, period: 10 },
}),
API_V2_RATE_LIMIT_PARTITION: formatMapleStage(stage),
// Production only: preview/stg workers run the same email crons against
// their own DB branches, so a binding here means every live stage sends
Expand Down
76 changes: 76 additions & 0 deletions apps/api/src/mcp/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { Env } from "@/platform/Env"
import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite"
import { ApiKeysService } from "@/services/org/ApiKeysService"
import { AuthService } from "@/services/auth/AuthService"
import type { RateLimiterApi } from "@/services/auth/ApiV2RateLimiter"
import { McpToolRateLimiter } from "@/services/auth/McpToolRateLimiter"
import { McpToolExecutor, type McpToolExecutorApi } from "./dispatcher"
import { type SessionPayload, sessionStore } from "./lib/session-store"
import { McpLive } from "./app"
Expand All @@ -18,6 +20,10 @@ const makeMcpToolExecutorStubLayer = (
Effect.succeed({ content: [{ type: "text" as const, text: "ok" }] }),
) => Layer.succeed(McpToolExecutor, { execute })

const makeRateLimiterStubLayer = (
check: RateLimiterApi["check"] = () => Effect.succeed("allowed" as const),
) => Layer.succeed(McpToolRateLimiter, { check })

const testConfig = () =>
ConfigProvider.layer(
ConfigProvider.fromUnknown({
Expand All @@ -40,6 +46,7 @@ describe("MCP HTTP authorization", () => {
ApiKeysService.layer,
AuthService.layer,
makeMcpToolExecutorStubLayer(),
makeRateLimiterStubLayer(),
).pipe(Layer.provideMerge(base))
const routes = McpLive.pipe(Layer.provideMerge(services))
const { handler, dispose } = HttpRouter.toWebHandler(routes, { disableLogger: true })
Expand Down Expand Up @@ -86,6 +93,7 @@ describe("MCP HTTP authorization", () => {
executedOrgId = tenant.orgId
return Effect.succeed({ content: [{ type: "text" as const, text: "ok" }] })
}),
makeRateLimiterStubLayer(),
).pipe(Layer.provideMerge(base))
const orgId = Schema.decodeUnknownSync(OrgId)("org_test")
const userId = Schema.decodeUnknownSync(UserId)("user_test")
Expand Down Expand Up @@ -202,6 +210,7 @@ describe("MCP HTTP authorization", () => {
ApiKeysService.layer,
AuthService.layer,
makeMcpToolExecutorStubLayer(),
makeRateLimiterStubLayer(),
).pipe(Layer.provideMerge(base))
const orgId = Schema.decodeUnknownSync(OrgId)("org_test")
const userId = Schema.decodeUnknownSync(UserId)("user_test")
Expand Down Expand Up @@ -286,6 +295,7 @@ describe("MCP HTTP authorization", () => {
ApiKeysService.layer,
AuthService.layer,
makeMcpToolExecutorStubLayer(),
makeRateLimiterStubLayer(),
).pipe(Layer.provideMerge(base))
const orgId = Schema.decodeUnknownSync(OrgId)("org_test")
const userId = Schema.decodeUnknownSync(UserId)("user_test")
Expand Down Expand Up @@ -366,4 +376,70 @@ describe("MCP HTTP authorization", () => {
await second.dispose()
}
})

it("refuses an over-budget credential with 429 and Retry-After", async () => {
const db = createTestDb(createdDbs)
const base = Layer.mergeAll(db.layer, Env.layer.pipe(Layer.provide(testConfig())))
const limitedKeys: string[] = []
let executed = false
const services = Layer.mergeAll(
ApiKeysService.layer,
AuthService.layer,
makeMcpToolExecutorStubLayer(() => {
executed = true
return Effect.succeed({ content: [{ type: "text" as const, text: "ok" }] })
}),
makeRateLimiterStubLayer((key) => {
limitedKeys.push(key)
return Effect.succeed("limited" as const)
}),
).pipe(Layer.provideMerge(base))
const orgId = Schema.decodeUnknownSync(OrgId)("org_test")
const userId = Schema.decodeUnknownSync(UserId)("user_test")
const key = await Effect.runPromise(
Effect.gen(function* () {
const apiKeys = yield* ApiKeysService
return yield* apiKeys.create(orgId, userId, { name: "Rate limit test", kind: "mcp" })
}).pipe(Effect.provide(services)),
)
const routes = McpLive.pipe(Layer.provideMerge(services))
const { handler, dispose } = HttpRouter.toWebHandler(routes, { disableLogger: true })
try {
const response = await handler(
new Request("https://api.example.com/mcp", {
method: "POST",
headers: {
authorization: `Bearer ${key.secret}`,
accept: "application/json, text/event-stream",
"content-type": "application/json",
host: "api.example.com",
"x-forwarded-proto": "https",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
},
}),
}),
Context.empty() as never,
)
const body = await response.clone().json()
expect({
status: response.status,
retryAfter: response.headers.get("retry-after"),
error: body.error,
executed,
}).toEqual({ status: 429, retryAfter: "10", error: "rate_limited", executed: false })
// Buckets are per internal key id, so a rolled key gets a fresh budget
// and the raw secret never reaches the counter key.
expect(limitedKeys).toEqual([`key:${key.id}`])
} finally {
await dispose()
}
})
})
42 changes: 38 additions & 4 deletions apps/api/src/mcp/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import type { McpToolExecutor } from "./dispatcher"
import { CurrentMcpRequestTenant, CurrentMcpTenant, resolveHttpMcpTenant } from "./lib/query-warehouse"
import { ApiKeysService } from "@/services/org/ApiKeysService"
import { AuthService } from "@/services/auth/AuthService"
import {
MCP_TOOLS_RATE_LIMIT_PERIOD_SECONDS,
MCP_TOOLS_RATE_LIMIT_REQUESTS,
McpToolRateLimiter,
} from "@/services/auth/McpToolRateLimiter"
import { Env } from "@/platform/Env"

const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"
Expand Down Expand Up @@ -93,11 +98,29 @@ const mcpUnavailable = () =>
),
)

// Wording mirrors the v2 envelope's `V2RateLimited`; the body stays in this
// surface's `{ error, message }` shape like the 401/503 responses above.
const mcpRateLimited = () =>
HttpServerResponse.jsonUnsafe(
{
error: "rate_limited",
message: "Too many requests. Retry after the interval in the Retry-After header.",
},
{
status: 429,
headers: {
"retry-after": String(MCP_TOOLS_RATE_LIMIT_PERIOD_SECONDS),
"cache-control": "no-store",
},
},
)

const McpAuthorizationMiddleware = HttpRouter.middleware<{ provides: CurrentMcpTenant }>()(
Effect.gen(function* () {
const apiKeys = yield* ApiKeysService
const auth = yield* AuthService
const env = yield* Env
const rateLimiter = yield* McpToolRateLimiter
return (httpEffect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
Expand All @@ -106,9 +129,20 @@ const McpAuthorizationMiddleware = HttpRouter.middleware<{ provides: CurrentMcpT
Effect.provideService(AuthService, auth),
Effect.provideService(Env, env),
Effect.flatMap((tenant) =>
Effect.provideService(httpEffect, CurrentMcpTenant, tenant).pipe(
Effect.provideService(CurrentMcpRequestTenant, tenant),
),
Effect.gen(function* () {
if (tenant.rateLimitCredentialId !== undefined) {
const outcome = yield* rateLimiter.check(tenant.rateLimitCredentialId)
yield* Effect.annotateCurrentSpan({
"maple.rate_limit.outcome": outcome,
"maple.rate_limit.limit": MCP_TOOLS_RATE_LIMIT_REQUESTS,
"maple.rate_limit.period_seconds": MCP_TOOLS_RATE_LIMIT_PERIOD_SECONDS,
})
if (outcome === "limited") return mcpRateLimited()
}
return yield* Effect.provideService(httpEffect, CurrentMcpTenant, tenant).pipe(
Effect.provideService(CurrentMcpRequestTenant, tenant),
)
}),
),
Effect.catchTags({
"@maple/mcp/errors/McpAuthMissingError": () => mcpChallenge(false),
Expand Down Expand Up @@ -137,7 +171,7 @@ const McpHttpLive = McpServer.layerHttp({
export const McpLive: Layer.Layer<
never,
Cause.IllegalArgumentError,
HttpRouter.HttpRouter | ApiKeysService | AuthService | Env | McpToolExecutor
HttpRouter.HttpRouter | ApiKeysService | AuthService | Env | McpToolExecutor | McpToolRateLimiter
> = Layer.mergeAll(
McpToolsLive,
DebugErrorsPrompt,
Expand Down
16 changes: 14 additions & 2 deletions apps/api/src/mcp/lib/resolve-tenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ import { recordExpectedMcpFailure } from "@/mcp/expected-failures"
import { sessionStore } from "@/mcp/lib/session-store"

const INTERNAL_SERVICE_PREFIX = "maple_svc_"

/**
* The tenant plus the rate-limit identity of the credential that produced it,
* consumed by the MCP authorization middleware. Absent only for internal
* service auth: that is Maple's own traffic behind one shared token, so a
* single bucket would throttle every internal caller together.
*/
export interface McpAuthenticatedTenant extends McpTenantContext {
readonly rateLimitCredentialId?: string
}
const decodeOrgId = Schema.decodeUnknownEffect(OrgId)
const decodeUserId = Schema.decodeUnknownEffect(UserId)
const decodeActorIdOption = Schema.decodeUnknownOption(ActorId)
Expand Down Expand Up @@ -136,7 +146,7 @@ export const resolveMcpTenantContext = Effect.fn("resolveMcpTenantContext")(
roles: [],
authMode: "self_hosted",
...(mcpClientName ? { mcpClientName } : undefined),
} as McpTenantContext
} as McpAuthenticatedTenant
}

return yield* new McpAuthInvalidError({
Expand Down Expand Up @@ -216,9 +226,10 @@ export const resolveMcpTenantContext = Effect.fn("resolveMcpTenantContext")(
userId: validUserId,
roles: resolved.roles ?? apiKeyDefaultRoles,
authMode: "self_hosted",
rateLimitCredentialId: `key:${resolved.keyId}`,
...(actorId ? { actorId } : undefined),
...(mcpClientName ? { mcpClientName } : undefined),
} as McpTenantContext
} as McpAuthenticatedTenant
}

// Fall back to existing Clerk / self-hosted session auth
Expand All @@ -238,6 +249,7 @@ export const resolveMcpTenantContext = Effect.fn("resolveMcpTenantContext")(
userId: tenant.userId,
roles: [...tenant.roles],
authMode: tenant.authMode,
rateLimitCredentialId: `user:${tenant.userId}`,
...(mcpClientName ? { mcpClientName } : undefined),
}
},
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/routes/v2/api-keys.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { AuthService } from "@/services/auth/AuthService"
import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService"
import { SharedDashboardService } from "@/services/dashboards/SharedDashboardService"
import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer"
import { ApiV2RateLimiter, type ApiV2RateLimiterApi } from "@/services/auth/ApiV2RateLimiter"
import { ApiV2RateLimiter, type RateLimiterApi } from "@/services/auth/ApiV2RateLimiter"
import { V2TransportErrorBoundaryLive } from "./error-envelope"
import {
AlertsServiceStubLayer,
Expand Down Expand Up @@ -49,7 +49,7 @@ const testConfig = () =>
)

const makeHarness = (
checkRateLimit: ApiV2RateLimiterApi["check"] = () => Effect.succeed("allowed" as const),
checkRateLimit: RateLimiterApi["check"] = () => Effect.succeed("allowed" as const),
) => {
const testDb = createTestDb(createdDbs)
const envLive = Env.layer.pipe(Layer.provide(testConfig()))
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/runtime/http-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import { ApiAuthorizationLayer } from "@/services/auth/ApiAuthorizationLayer"
import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer"
import { SessionAuthorizationLayer } from "@/services/auth/SessionAuthorizationLayer"
import { ApiV2RateLimiter } from "@/services/auth/ApiV2RateLimiter"
import { McpToolRateLimiter } from "@/services/auth/McpToolRateLimiter"
import { EdgeCacheService } from "@maple/cache"
import { CacheBackendLive } from "@/platform/CacheBackendLive"
import { OrgMembershipService } from "@/services/auth/OrgMembershipService"
Expand Down Expand Up @@ -183,6 +184,7 @@ export const ApiAuthLive = Layer.mergeAll(
SessionAuthorizationLayer,
).pipe(
Layer.provideMerge(ApiV2RateLimiter.layer),
Layer.provideMerge(McpToolRateLimiter.layer),
Layer.provideMerge(ApiKeysService.layer),
// Membership verification for `x-maple-org-id`. Only the v2 layer asks for
// it; without it that layer cannot build, which is deliberate — the header
Expand Down
Loading
Loading