Skip to content
Open
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
97 changes: 57 additions & 40 deletions apps/api/src/mcp/__evals__/regression.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import { afterAll, assert, beforeAll, describe, it } from "@effect/vitest"
import { Effect } from "effect"
import { installFakeWarehouse, restoreWarehouse, type FixtureRule } from "./fake-warehouse"
import { makeEvalRuntime, runToolDirect, type EvalRuntime } from "./eval-runtime"
import {
Expand Down Expand Up @@ -58,48 +59,64 @@ afterAll(async () => {
})

describe("inspect_trace bounded-overview rendering", () => {
it("renders a small trace in full (no truncation note)", async () => {
const result = await runToolDirect(rt, "inspect_trace", { trace_id: SMALL_TRACE_ID })
const text = renderedText(result)
expect(text).not.toContain("Showing")
expect(text).toContain("GET /api/orders")
// Span ids are surfaced at the end of each line for follow-up lookups.
expect(text).toContain("span=")
})
it.effect("renders a small trace in full (no truncation note)", () =>
Effect.gen(function* () {
const result = yield* Effect.promise(() =>
runToolDirect(rt, "inspect_trace", { trace_id: SMALL_TRACE_ID }),
)
const text = renderedText(result)
assert.notInclude(text, "Showing")
assert.include(text, "GET /api/orders")
// Span ids are surfaced at the end of each line for follow-up lookups.
assert.include(text, "span=")
}),
)

it("caps a large trace and keeps the error span + omitted marker", async () => {
const result = await runToolDirect(rt, "inspect_trace", { trace_id: FIXTURES.traceId })
const text = renderedText(result)
// Bounded overview note.
expect(text).toContain(`of ${LARGE_TRACE_SPAN_COUNT} spans (errors and longest first)`)
// The single error span survives selection even though it's low-duration.
expect(text).toContain("[Error]")
expect(text).toContain("db.query users")
// Dropped siblings are surfaced, not silently hidden.
expect(text).toContain("more spans")
// Full span ids remain available for inspect_span pivots.
expect(text).toContain("span=")
})
it.effect("caps a large trace and keeps the error span + omitted marker", () =>
Effect.gen(function* () {
const result = yield* Effect.promise(() =>
runToolDirect(rt, "inspect_trace", { trace_id: FIXTURES.traceId }),
)
const text = renderedText(result)
// Bounded overview note.
assert.include(text, `of ${LARGE_TRACE_SPAN_COUNT} spans (errors and longest first)`)
// The single error span survives selection even though it's low-duration.
assert.include(text, "[Error]")
assert.include(text, "db.query users")
// Dropped siblings are surfaced, not silently hidden.
assert.include(text, "more spans")
// Full span ids remain available for inspect_span pivots.
assert.include(text, "span=")
}),
)
})

describe("inspect_span drill-down", () => {
it("returns the full attribute set for a known span", async () => {
const result = await runToolDirect(rt, "inspect_span", {
trace_id: SPAN_DETAIL_TRACE_ID,
span_id: SPAN_DETAIL_SPAN_ID,
})
const text = renderedText(result)
expect(text).toContain("http.method")
expect(text).toContain("POST")
expect(text).toContain("/api/checkout")
})
it.effect("returns the full attribute set for a known span", () =>
Effect.gen(function* () {
const result = yield* Effect.promise(() =>
runToolDirect(rt, "inspect_span", {
trace_id: SPAN_DETAIL_TRACE_ID,
span_id: SPAN_DETAIL_SPAN_ID,
}),
)
const text = renderedText(result)
assert.include(text, "http.method")
assert.include(text, "POST")
assert.include(text, "/api/checkout")
}),
)

it("reports a friendly message for an unknown span (no crash)", async () => {
const result = await runToolDirect(rt, "inspect_span", {
trace_id: SPAN_DETAIL_TRACE_ID,
span_id: MISSING_SPAN_ID,
})
const text = renderedText(result)
expect(text.toLowerCase()).toContain("not found")
})
it.effect("reports a friendly message for an unknown span (no crash)", () =>
Effect.gen(function* () {
const result = yield* Effect.promise(() =>
runToolDirect(rt, "inspect_span", {
trace_id: SPAN_DETAIL_TRACE_ID,
span_id: MISSING_SPAN_ID,
}),
)
const text = renderedText(result)
assert.include(text.toLowerCase(), "not found")
}),
)
})
150 changes: 76 additions & 74 deletions apps/api/src/mcp/app.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from "@effect/vitest"
import { afterEach, assert, describe, it } from "@effect/vitest"
import { OrgId, UserId } from "@maple/domain/http"
import { ConfigProvider, Context, Effect, Layer, Schema } from "effect"
import { HttpRouter } from "effect/unstable/http"
Expand Down Expand Up @@ -26,99 +26,101 @@ const testConfig = () =>
)

describe("MCP HTTP authorization", () => {
it("challenges unauthenticated clients before MCP initialization", async () => {
it.effect("challenges unauthenticated clients before MCP initialization", () => {
const db = createTestDb(createdDbs)
const base = Layer.mergeAll(db.layer, Env.layer.pipe(Layer.provide(testConfig())))
const services = Layer.mergeAll(ApiKeysService.layer, AuthService.layer).pipe(
Layer.provideMerge(base),
)
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: {
"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-11-25",
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },

return Effect.gen(function* () {
const { handler, dispose } = HttpRouter.toWebHandler(routes, { disableLogger: true })
const response = yield* Effect.promise(() =>
handler(
new Request("https://api.example.com/mcp", {
method: "POST",
headers: {
"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-11-25",
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
},
}),
}),
}),
Context.empty() as never,
)
expect(response.status).toBe(401)
expect(response.headers.get("www-authenticate")).toContain(
Context.empty() as never,
),
).pipe(Effect.ensuring(Effect.promise(() => dispose())))

assert.strictEqual(response.status, 401)
assert.include(
response.headers.get("www-authenticate") ?? "",
'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"',
)
expect(response.headers.get("www-authenticate")).toContain('scope="mcp:tools"')
} finally {
await dispose()
}
assert.include(response.headers.get("www-authenticate") ?? "", 'scope="mcp:tools"')
})
})

it("accepts an audience-bound OAuth key behind a forwarded HTTPS proxy", async () => {
it.effect("accepts an audience-bound OAuth key behind a forwarded HTTPS proxy", () => {
const db = createTestDb(createdDbs)
const base = Layer.mergeAll(db.layer, Env.layer.pipe(Layer.provide(testConfig())))
const services = Layer.mergeAll(ApiKeysService.layer, AuthService.layer).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: "OAuth MCP test",
kind: "mcp",
scopes: ["mcp:tools"],
metadataJson: {
source: "maple_mcp_oauth",
roles: ["org:member"],
clientId: "client_test",
resource: "https://api.example.com/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("http://internal-worker.invalid/mcp", {
method: "POST",
headers: {
authorization: `Bearer ${key.secret}`,
"content-type": "application/json",
host: "internal-worker.invalid",
"x-forwarded-host": "api.example.com",
"x-forwarded-proto": "https",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },

return Effect.gen(function* () {
const apiKeys = yield* ApiKeysService
const key = yield* apiKeys.create(orgId, userId, {
name: "OAuth MCP test",
kind: "mcp",
scopes: ["mcp:tools"],
metadataJson: {
source: "maple_mcp_oauth",
roles: ["org:member"],
clientId: "client_test",
resource: "https://api.example.com/mcp",
},
})

const routes = McpLive.pipe(Layer.provideMerge(services))
const { handler, dispose } = HttpRouter.toWebHandler(routes, { disableLogger: true })
const response = yield* Effect.promise(() =>
handler(
new Request("http://internal-worker.invalid/mcp", {
method: "POST",
headers: {
authorization: `Bearer ${key.secret}`,
"content-type": "application/json",
host: "internal-worker.invalid",
"x-forwarded-host": "api.example.com",
"x-forwarded-proto": "https",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
},
}),
}),
}),
Context.empty() as never,
)
expect(response.status).toBe(200)
} finally {
await dispose()
}
Context.empty() as never,
),
).pipe(Effect.ensuring(Effect.promise(() => dispose())))

assert.strictEqual(response.status, 200)
}).pipe(Effect.provide(services))
})
})
23 changes: 12 additions & 11 deletions apps/api/src/mcp/dispatcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "@effect/vitest"
import { assert, describe, it } from "@effect/vitest"
import { Effect, Schema } from "effect"
import type { InternalRpcToolNotFoundError } from "@maple/domain/internal-rpc"
import { callMcpTool, listMcpTools } from "./dispatcher"
Expand All @@ -16,13 +16,14 @@ describe("MCP dispatcher", () => {
}))
.filter(({ type }) => type !== "object")

expect(invalidSchemas).toEqual([])
assert.deepStrictEqual(invalidSchemas, [])
})

it.effect("publishes the same names, descriptions, and schemas used by HTTP MCP", () =>
Effect.gen(function* () {
const descriptors = yield* listMcpTools
expect(descriptors).toEqual(
assert.deepStrictEqual(
descriptors,
mapleToolDefinitions.map((definition) => ({
name: definition.name,
description: definition.description,
Expand All @@ -35,16 +36,16 @@ describe("MCP dispatcher", () => {
it("normalizes an empty Struct root and rejects a non-object root", () => {
// Effect emits `{ anyOf: [{type:"object"},{type:"array"}] }` — no `type` —
// for a no-parameter tool; that exact shape is normalized.
expect(toInputSchema(Schema.Struct({}))).toEqual({
assert.deepStrictEqual(toInputSchema(Schema.Struct({})), {
type: "object",
properties: {},
additionalProperties: false,
})

// Anything else with a non-object root has parameters an empty object
// schema would erase, so registration fails loudly instead.
expect(() => toInputSchema(Schema.Literals(["a", "b"]))).toThrow(/object root/)
expect(() => toInputSchema(Schema.Array(Schema.String))).toThrow(/object root/)
assert.throws(() => toInputSchema(Schema.Literals(["a", "b"])), /object root/)
assert.throws(() => toInputSchema(Schema.Array(Schema.String)), /object root/)
})

it.effect("returns MCP validation feedback for invalid model tool input", () =>
Expand All @@ -57,9 +58,9 @@ describe("MCP dispatcher", () => {
never,
never
>
expect(result.isError).toBe(true)
expect(result.content[0]?.text).toContain("Invalid parameters")
expect(result.content[0]?.text).toContain("inspect_trace")
assert.strictEqual(result.isError, true)
assert.include(result.content[0]?.text ?? "", "Invalid parameters")
assert.include(result.content[0]?.text ?? "", "inspect_trace")
}),
)

Expand All @@ -72,8 +73,8 @@ describe("MCP dispatcher", () => {
never
>,
)
expect(error._tag).toBe("@maple/internal-rpc/ToolNotFoundError")
expect(error.name).toBe("not_a_maple_tool")
assert.strictEqual(error._tag, "@maple/internal-rpc/ToolNotFoundError")
assert.strictEqual(error.name, "not_a_maple_tool")
}),
)
})
Loading
Loading