diff --git a/apps/api/src/mcp/app.ts b/apps/api/src/mcp/app.ts index 900640585..867536a2d 100644 --- a/apps/api/src/mcp/app.ts +++ b/apps/api/src/mcp/app.ts @@ -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" @@ -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, diff --git a/apps/api/src/routes/discovery.http.test.ts b/apps/api/src/routes/discovery.http.test.ts new file mode 100644 index 000000000..3cd107ba4 --- /dev/null +++ b/apps/api/src/routes/discovery.http.test.ts @@ -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), + ) + 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") + }) +}) diff --git a/apps/api/src/routes/discovery.http.ts b/apps/api/src/routes/discovery.http.ts new file mode 100644 index 000000000..4f9e833b8 --- /dev/null +++ b/apps/api/src/routes/discovery.http.ts @@ -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 }, + ), + ) + }), +) diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index 49c15f9be..036a040d8 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -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" @@ -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( diff --git a/apps/landing/messages/en.json b/apps/landing/messages/en.json index 7b9be4690..75b27e5ab 100644 --- a/apps/landing/messages/en.json +++ b/apps/landing/messages/en.json @@ -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", diff --git a/apps/landing/messages/ja.json b/apps/landing/messages/ja.json index 96d281769..75a3f8f49 100644 --- a/apps/landing/messages/ja.json +++ b/apps/landing/messages/ja.json @@ -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", diff --git a/apps/landing/messages/ko.json b/apps/landing/messages/ko.json index a52786a10..f2b57e694 100644 --- a/apps/landing/messages/ko.json +++ b/apps/landing/messages/ko.json @@ -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", diff --git a/apps/landing/package.json b/apps/landing/package.json index f8ec17f42..0fee8ca09 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -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" @@ -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", @@ -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", @@ -39,7 +42,8 @@ }, "devDependencies": { "@types/d3-scale": "^4.0.9", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "catalog:" }, "portless": { "name": "landing", diff --git a/apps/landing/public/robots.txt b/apps/landing/public/robots.txt index 2221cd619..bbae655c0 100644 --- a/apps/landing/public/robots.txt +++ b/apps/landing/public/robots.txt @@ -46,5 +46,10 @@ Disallow: # .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 diff --git a/apps/landing/src/__tests__/company-pages.test.ts b/apps/landing/src/__tests__/company-pages.test.ts new file mode 100644 index 000000000..daf3ca642 --- /dev/null +++ b/apps/landing/src/__tests__/company-pages.test.ts @@ -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[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/") + }) +}) diff --git a/apps/landing/src/__tests__/openapi-json.test.ts b/apps/landing/src/__tests__/openapi-json.test.ts new file mode 100644 index 000000000..c78c23f0d --- /dev/null +++ b/apps/landing/src/__tests__/openapi-json.test.ts @@ -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[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), + ) + 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)) + } + }) +}) diff --git a/apps/landing/src/__tests__/well-known-mcp.test.ts b/apps/landing/src/__tests__/well-known-mcp.test.ts new file mode 100644 index 000000000..c1878bf12 --- /dev/null +++ b/apps/landing/src/__tests__/well-known-mcp.test.ts @@ -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[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()) + }) +}) diff --git a/apps/landing/src/components/CompanyContent.astro b/apps/landing/src/components/CompanyContent.astro new file mode 100644 index 000000000..c5bea9c1e --- /dev/null +++ b/apps/landing/src/components/CompanyContent.astro @@ -0,0 +1,51 @@ +--- +/** + * Renders a `CompanyPage` (see `lib/company.ts`) with the same typography as + * `PrivacyContent.astro`, so /about and /contact read as siblings of /privacy. + * Paragraphs are markdown with inline links only, converted here so the copy + * stays a single literal string shared with the `.md` twin. + */ +import type { CompanyPage } from "../lib/company"; + +interface Props { + page: CompanyPage; +} + +const { page } = Astro.props; + +const escape = (text: string) => + text.replace(/&/g, "&").replace(//g, ">"); + +/** `[text](url)`, `` `code` `` and `**bold**` → HTML; everything else escaped. */ +const inline = (markdown: string) => + escape(markdown) + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + '$1', + ); +--- + +
+

{page.title}

+

{page.description}

+ +
+ {page.sections.map((section) => ( +
+

{section.heading}

+ {section.paragraphs.map((paragraph) => ( +

+ ))} + {section.bullets && ( +

    + {section.bullets.map((bullet) => ( +
  • + ))} +
+ )} +
+ ))} +
+
diff --git a/apps/landing/src/components/Footer.astro b/apps/landing/src/components/Footer.astro index 210b7242d..4b4a27b08 100644 --- a/apps/landing/src/components/Footer.astro +++ b/apps/landing/src/components/Footer.astro @@ -116,6 +116,9 @@ const stars = await getGitHubStars(); {m.bill_v_apache()}
+ + {m.footer_about()} + {m.footer_contact()} {m.footer_privacy()} {m.footer_terms()} +

+ Looking for something specific? + Docs + · + Pricing + · + Changelog + · + Contact + · + Sitemap + · + llms.txt +