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
4 changes: 3 additions & 1 deletion apps/api/src/mcp/app.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { MAPLE_MCP_SERVER_VERSION } from "@maple/domain/mcp-manifest"
import { McpProtocol, McpServer } from "effect/unstable/ai"
import { Cause, Effect, Layer } from "effect"
import { Headers, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
Expand Down Expand Up @@ -126,7 +127,8 @@ const McpAuthorizationMiddleware = HttpRouter.middleware<{ provides: CurrentMcpT

const McpHttpLive = McpServer.layerHttp({
name: "maple-observability",
version: "1.0.0",
// Kept equal to the public `server.json` manifest (`@maple/domain/mcp-manifest`).
version: MAPLE_MCP_SERVER_VERSION,
path: "/mcp",
protocols: MCP_PROTOCOLS,
clientSessions: sessionStore,
Expand Down
96 changes: 96 additions & 0 deletions apps/api/src/routes/discovery.http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { afterAll, describe, expect, it } from "@effect/vitest"
import { MAPLE_MCP_SERVER_NAME } from "@maple/domain/mcp-manifest"
import { Effect, Layer } from "effect"
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
import { DiscoveryRouter, NotFoundRouter } from "./discovery.http"

// A registered route that must keep winning over the catch-all.
const ProbeRouter = HttpRouter.use((router) =>
Effect.gen(function* () {
yield* router.add("GET", "/probe", HttpServerResponse.text("probe"))
yield* router.add("GET", "/things/:id", HttpServerResponse.text("thing"))
}),
)

const { handler, dispose } = HttpRouter.toWebHandler(
Layer.mergeAll(DiscoveryRouter, NotFoundRouter, ProbeRouter),
{ disableLogger: true },
)
afterAll(() => dispose())

const get = (path: string, init?: RequestInit) =>
handler(
new Request(`https://api.example.com${path}`, {
headers: { host: "api.example.com", "x-forwarded-proto": "https" },
...init,
}),
)

describe("DiscoveryRouter", () => {
it("serves the v2 OpenAPI document as JSON at /openapi.json and /v2/openapi.json", async () => {
for (const path of ["/openapi.json", "/v2/openapi.json"]) {
const response = await get(path)
expect(response.status).toBe(200)
expect(response.headers.get("content-type")).toContain("application/json")
const doc = await response.json()
expect(doc.openapi).toMatch(/^3\.1\./)
expect(doc.info.title).toBe("Maple API")
expect(doc.servers).toEqual([{ url: "https://api.maple.dev", description: "Production" }])
expect(Object.keys(doc.paths).length).toBeGreaterThan(20)
const operations = Object.values(doc.paths).flatMap((item) =>
Object.values(item as Record<string, { operationId?: string; description?: string }>),
)
for (const operation of operations) {
expect(operation.operationId).toEqual(expect.any(String))
expect(operation.description).toEqual(expect.any(String))
}
}
})

it("serves the MCP server.json manifest under /.well-known", async () => {
for (const path of ["/.well-known/mcp.json", "/.well-known/mcp/server.json"]) {
const response = await get(path)
expect(response.status).toBe(200)
expect(response.headers.get("content-type")).toContain("application/json")
const manifest = await response.json()
expect(manifest.name).toBe(MAPLE_MCP_SERVER_NAME)
expect(manifest.remotes).toEqual([
expect.objectContaining({ type: "streamable-http", url: "https://api.example.com/mcp" }),
])
}
})

it("answers the bare origin with a JSON index", async () => {
const response = await get("/")
expect(response.status).toBe(200)
const index = await response.json()
expect(index.openapi).toBe("https://api.example.com/openapi.json")
expect(index.mcp.endpoint).toBe("https://api.example.com/mcp")
})
})

describe("NotFoundRouter", () => {
it("returns the v2 error envelope for an unmatched path on any method", async () => {
for (const method of ["GET", "POST", "DELETE"]) {
const response = await get("/v2/does-not-exist?x=1", { method })
expect(response.status).toBe(404)
expect(response.headers.get("content-type")).toContain("application/json")
const body = await response.json()
expect(body.error).toMatchObject({
_tag: "@maple/http/v2/RouteNotFoundError",
type: "not_found_error",
code: "route_not_found",
retryable: false,
recovery: "fix_request",
})
expect(body.error.message).toContain(`${method} /v2/does-not-exist`)
expect(body.error.message).toContain("https://api.example.com/openapi.json")
expect(body.error.message).toContain("https://api.example.com/v2/docs")
}
})

it("never shadows a registered static or parametric route", async () => {
expect(await (await get("/probe")).text()).toBe("probe")
expect(await (await get("/things/42")).text()).toBe("thing")
})
})
94 changes: 94 additions & 0 deletions apps/api/src/routes/discovery.http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { MapleApiV2, v2RouteNotFoundBody } from "@maple/domain/http/v2"
import { mapleMcpServerManifest } from "@maple/domain/mcp-manifest"
import { Effect } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { OpenApi } from "effect/unstable/httpapi"
import { requestOrigin } from "@/routes/v1/oauth-discovery.http"

/**
* Machine discovery for agents and tooling.
*
* - `GET /openapi.json` (+ `/v2/openapi.json`) — the public v2 OpenAPI 3.1
* document, the same one `/v2/docs` renders. Built once per isolate: the
* derivation walks every group's schemas and is not free.
* - `GET /.well-known/mcp.json` (+ `/.well-known/mcp/server.json`) — the MCP
* registry `server.json` for the hosted MCP server at `/mcp`.
* - `GET /` — a JSON index pointing at all of the above, so the bare origin
* is self-describing instead of an empty 404.
*
* Everything here is public, unauthenticated, and cacheable.
*/

const PUBLIC_CACHE = { "cache-control": "public, max-age=300" }

let openApiDocument: string | undefined
const openApiJson = Effect.sync(() => {
openApiDocument ??= JSON.stringify(OpenApi.fromApi(MapleApiV2))
return openApiDocument
})

const apiIndex = (origin: string) => ({
name: "Maple API",
documentation: `${origin}/v2/docs`,
openapi: `${origin}/openapi.json`,
mcp: { endpoint: `${origin}/mcp`, manifest: `${origin}/.well-known/mcp.json` },
health: `${origin}/health`,
website: "https://maple.dev",
llms_txt: "https://maple.dev/llms.txt",
})

export const DiscoveryRouter = HttpRouter.use((router) =>
Effect.gen(function* () {
const serveOpenApi = Effect.map(openApiJson, (body) =>
HttpServerResponse.text(body, {
status: 200,
contentType: "application/json; charset=utf-8",
headers: PUBLIC_CACHE,
}),
)
yield* router.add("GET", "/openapi.json", serveOpenApi)
yield* router.add("GET", "/v2/openapi.json", serveOpenApi)

const serveManifest = (request: HttpServerRequest.HttpServerRequest) =>
Effect.succeed(
HttpServerResponse.jsonUnsafe(
mapleMcpServerManifest({ apiBaseUrl: requestOrigin(request) }),
{
headers: PUBLIC_CACHE,
},
),
)
yield* router.add("GET", "/.well-known/mcp.json", serveManifest)
yield* router.add("GET", "/.well-known/mcp/server.json", serveManifest)

yield* router.add("GET", "/", (request) =>
Effect.succeed(
HttpServerResponse.jsonUnsafe(apiIndex(requestOrigin(request)), { headers: PUBLIC_CACHE }),
),
)
}),
)

/**
* Lowest-precedence catch-all: find-my-way ranks a wildcard below every static
* and parametric route, so this only fires when nothing else matched. Without
* it the router's `RouteNotFound` surfaces as a bodyless 404, which an agent
* cannot distinguish from "this resource does not exist".
*/
export const NotFoundRouter = HttpRouter.use((router) =>
router.add("*", "/*", (request) => {
const origin = requestOrigin(request)
const path = request.url.split("?")[0] ?? request.url
return Effect.succeed(
HttpServerResponse.jsonUnsafe(
{
error: v2RouteNotFoundBody(request.method, path, {
openApiUrl: `${origin}/openapi.json`,
docsUrl: `${origin}/v2/docs`,
}),
},
{ status: 404 },
),
)
}),
)
5 changes: 5 additions & 0 deletions apps/api/src/runtime/http-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ChatSessionsRouter } from "@/routes/v1/chat-sessions.http"
import { HttpChatLive } from "@/routes/internal/chat.http"
import { V1ErrorBoundaryLive } from "@/routes/v1/error-boundary"
import { HttpDemoLive } from "@/routes/internal/demo.http"
import { DiscoveryRouter, NotFoundRouter } from "@/routes/discovery.http"
import { HttpDigestLive } from "@/routes/internal/digest.http"
import { HttpErrorsLive } from "@/routes/v1/errors.http"
import { HttpIntegrationsLive, IntegrationsCallbackRouter } from "@/routes/v1/integrations.http"
Expand Down Expand Up @@ -168,6 +169,10 @@ export const AllRoutes = Layer.mergeAll(
HealthRouter,
DocsRoute,
DocsV2Route,
DiscoveryRouter,
// Last by convention only — find-my-way ranks the wildcard below every other
// route regardless of registration order.
NotFoundRouter,
).pipe(Layer.provideMerge(HttpRouter.cors(API_CORS_OPTIONS)))

export const ApiAuthLive = Layer.mergeAll(
Expand Down
2 changes: 2 additions & 0 deletions apps/landing/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,8 @@
"footer_privacy": "Privacy Policy",
"footer_terms": "Terms of Service",
"footer_brand": "Brand",
"footer_about": "About",
"footer_contact": "Contact",

"pricing_30day_retention": "30-day retention",
"pricing_unlimited_dashboards": "Unlimited dashboards",
Expand Down
2 changes: 2 additions & 0 deletions apps/landing/messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,8 @@
"footer_privacy": "\u30d7\u30e9\u30a4\u30d0\u30b7\u30fc\u30dd\u30ea\u30b7\u30fc",
"footer_terms": "\u5229\u7528\u898f\u7d04",
"footer_brand": "\u30d6\u30e9\u30f3\u30c9",
"footer_about": "\u4f1a\u793e\u6982\u8981",
"footer_contact": "\u304a\u554f\u3044\u5408\u308f\u305b",

"pricing_30day_retention": "30\u65e5\u9593\u306e\u4fdd\u6301",
"pricing_unlimited_dashboards": "\u7121\u5236\u9650\u306e\u30c0\u30c3\u30b7\u30e5\u30dc\u30fc\u30c9",
Expand Down
2 changes: 2 additions & 0 deletions apps/landing/messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,8 @@
"footer_privacy": "\uac1c\uc778\uc815\ubcf4 \ucc98\ub9ac\ubc29\uce68",
"footer_terms": "\uc774\uc6a9\uc57d\uad00",
"footer_brand": "\ube0c\ub79c\ub4dc",
"footer_about": "\uc18c\uac1c",
"footer_contact": "\ubb38\uc758",

"pricing_30day_retention": "30\uc77c \ubcf4\uc874",
"pricing_unlimited_dashboards": "\ubb34\uc81c\ud55c \ub300\uc2dc\ubcf4\ub4dc",
Expand Down
6 changes: 5 additions & 1 deletion apps/landing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"sync:i18n": "astro sync",
"sync:cli": "mkdir -p public/cli && cp ../../scripts/install.sh public/cli/install && cp ../../scripts/uninstall.sh public/cli/uninstall",
"dev:app": "bun run sync:cli && astro dev --port ${PORT:-3391} --host ${HOST:-127.0.0.1}",
"test": "vitest run",
"build": "bun run sync:cli && astro build",
"preview": "astro preview",
"astro": "astro"
Expand All @@ -21,6 +22,7 @@
"@fontsource-variable/geist-mono": "^5.2.8",
"@inlang/paraglide-astro": "^0.4.1",
"@maple-dev/browser": "workspace:*",
"@maple/domain": "workspace:*",
"@maple/infra": "workspace:*",
"@maple/ui": "workspace:*",
"@tailwindcss/vite": "catalog:tailwind",
Expand All @@ -30,6 +32,7 @@
"astro": "^5.17.1",
"autumn-js": "^1.2.51",
"d3-scale": "^4.0.2",
"effect": "catalog:effect",
"fuse.js": "^7.4.2",
"motion": "^12.40.0",
"react": "catalog:react",
Expand All @@ -39,7 +42,8 @@
},
"devDependencies": {
"@types/d3-scale": "^4.0.9",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"vitest": "catalog:"
},
"portless": {
"name": "landing",
Expand Down
5 changes: 5 additions & 0 deletions apps/landing/public/robots.txt
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,10 @@ Disallow:
# <path>.md, indexed at /llms.txt. Content negotiation works too —
# `Accept: text/markdown` on any page URL returns the same source.
# https://maple.dev/llms.txt
#
# Machine-readable API surface:
# OpenAPI 3.1: https://maple.dev/openapi.json
# MCP server.json: https://maple.dev/.well-known/mcp.json
# API reference: https://api.maple.dev/v2/docs

Sitemap: https://maple.dev/sitemap-index.xml
45 changes: 45 additions & 0 deletions apps/landing/src/__tests__/company-pages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest"
import { aboutPage, contactPage } from "../lib/company"
import { companyPageMarkdown } from "../lib/company-markdown"
import { GET as aboutMd } from "../pages/about.md"
import { GET as contactMd } from "../pages/contact.md"

const context = { site: new URL("https://maple.dev") } as Parameters<typeof aboutMd>[0]

const plainLength = (page: typeof aboutPage) =>
page.sections.flatMap((s) => [...s.paragraphs, ...(s.bullets ?? [])]).join(" ").length

describe("trust pages", () => {
it("carry substantive content (well over the 500-character floor answer engines check)", () => {
expect(plainLength(aboutPage)).toBeGreaterThan(1500)
expect(plainLength(contactPage)).toBeGreaterThan(1000)
})

it("name the legal entity and a contact address", () => {
const about = JSON.stringify(aboutPage)
const contact = JSON.stringify(contactPage)
expect(about).toContain("Makisuo, Inc.")
expect(contact).toContain("mailto:support@maple.dev")
expect(contact).toContain("mailto:privacy@getmaple.dev")
})

it("render .md twins with absolute links and the same headings", async () => {
for (const [route, page] of [
[aboutMd, aboutPage],
[contactMd, contactPage],
] as const) {
const response = await route(context)
expect(response.headers.get("Content-Type")).toBe("text/markdown; charset=utf-8")
const body = await response.text()
expect(body.startsWith(`# ${page.title}\n`)).toBe(true)
for (const section of page.sections) expect(body).toContain(`## ${section.heading}`)
expect(body).not.toMatch(/\]\(\//)
expect(body).toContain("](https://maple.dev/")
}
})

it("keeps bullets as a markdown list", async () => {
const body = await companyPageMarkdown(contactPage, new URL("https://maple.dev")).text()
expect(body).toContain("- [Discord](https://discord.gg/")
})
})
30 changes: 30 additions & 0 deletions apps/landing/src/__tests__/openapi-json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest"
import { GET, openApiDocument } from "../pages/openapi.json"

// Endpoint tests only need `site`; the rest of APIContext is unused.
const context = { site: new URL("https://maple.dev") } as Parameters<typeof GET>[0]

describe("/openapi.json", () => {
it("serves a JSON OpenAPI 3.1 document that targets the API origin", async () => {
const response = await GET(context)
expect(response.headers.get("Content-Type")).toBe("application/json; charset=utf-8")
const doc = await response.json()
expect(doc.openapi).toMatch(/^3\.1\./)
expect(doc.info.title).toBe("Maple API")
expect(doc.servers).toEqual([{ url: "https://api.maple.dev", description: "Production" }])
})

it("is function-calling ready: every operation has an operationId and a description", () => {
const doc = openApiDocument()
const operations = Object.values(doc.paths).flatMap((item) =>
Object.values(item as Record<string, { operationId?: string; description?: string }>),
)
expect(operations.length).toBeGreaterThan(50)
const ids = operations.map((operation) => operation.operationId)
expect(new Set(ids).size).toBe(ids.length)
for (const operation of operations) {
expect(operation.operationId).toEqual(expect.any(String))
expect(operation.description).toEqual(expect.any(String))
}
})
})
23 changes: 23 additions & 0 deletions apps/landing/src/__tests__/well-known-mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { MAPLE_MCP_SERVER_NAME } from "@maple/domain/mcp-manifest"
import { describe, expect, it } from "vitest"
import { GET } from "../pages/.well-known/mcp.json"
import { GET as GET_SERVER_JSON } from "../pages/.well-known/mcp/server.json"

const context = { site: new URL("https://maple.dev") } as Parameters<typeof GET>[0]

describe("/.well-known/mcp.json", () => {
it("publishes the registry server.json pointing at the hosted MCP server", async () => {
const response = await GET(context)
expect(response.headers.get("Content-Type")).toBe("application/json; charset=utf-8")
const manifest = await response.json()
expect(manifest.name).toBe(MAPLE_MCP_SERVER_NAME)
expect(manifest.remotes).toEqual([
expect.objectContaining({ type: "streamable-http", url: "https://api.maple.dev/mcp" }),
])
expect(manifest.websiteUrl).toBe("https://maple.dev/features/ai-mcp-integration")
})

it("serves the same document at /.well-known/mcp/server.json", async () => {
expect(await (await GET_SERVER_JSON(context)).text()).toBe(await (await GET(context)).text())
})
})
Loading
Loading