diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 3e0f72e..8b78d90 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -36,6 +36,7 @@ Tests that need a Docker daemon skip themselves when the socket is missing, so a | `apps/gateway/test/integration/` | A real HTTP server on a real socket, driven end to end | | `apps/web/` | The admin UI, Vue 3 with shadcn-vue components | | `apps/web/scripts/` | Maintenance for the source catalog, run from CI | +| `apps/site/` | The landing page and rendered documentation, Astro with Vue islands, deployed to Cloudflare Pages | | `packages/schema/` | Zod schemas and DTO types shared by the gateway and the UI | | `docs/` | Reference pages and deployment guides linked from the README | | `.github/` | CI workflows, issue and pull request templates, and the community health files | diff --git a/.gitignore b/.gitignore index 77b7f56..03f4fc3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ apps/gateway/data/ junctio .prototype/ .cache/ +.astro/ diff --git a/apps/site/README.md b/apps/site/README.md new file mode 100644 index 0000000..a19abe8 --- /dev/null +++ b/apps/site/README.md @@ -0,0 +1,12 @@ +# Junctio site + +The landing page and the rendered documentation at junctio.pages.dev, built with Astro, Tailwind 4 and Vue islands. + +The documentation pages are generated from `../../docs/*.md` at build time; nothing under `src/content` duplicates them. Use-case pages live in `src/content/use-cases` and are the only prose authored here. + +```bash +bun run dev:site # astro dev server on 4321 +bun run build:site # static output in apps/site/dist +``` + +Cloudflare Pages settings: root directory `apps/site`, build command `bun run build`, output directory `dist`. Set `SITE_URL` once the site has its own domain so canonical URLs and the sitemap follow it. diff --git a/apps/site/astro.config.mjs b/apps/site/astro.config.mjs new file mode 100644 index 0000000..cb6bf08 --- /dev/null +++ b/apps/site/astro.config.mjs @@ -0,0 +1,25 @@ +import { defineConfig } from "astro/config"; +import { unified } from "@astrojs/markdown-remark"; +import mdx from "@astrojs/mdx"; +import sitemap from "@astrojs/sitemap"; +import vue from "@astrojs/vue"; +import tailwindcss from "@tailwindcss/vite"; +import { rehypeDocLinks } from "./src/lib/rehype-doc-links.ts"; + +export default defineConfig({ + site: process.env.SITE_URL ?? "https://junctio.pages.dev", + trailingSlash: "always", + integrations: [vue(), mdx(), sitemap()], + markdown: { + processor: unified({ rehypePlugins: [rehypeDocLinks] }), + shikiConfig: { + themes: { light: "github-light", dark: "github-dark" } + } + }, + vite: { + plugins: [tailwindcss()], + server: { + fs: { allow: ["../.."] } + } + } +}); diff --git a/apps/site/package.json b/apps/site/package.json new file mode 100644 index 0000000..d776e4d --- /dev/null +++ b/apps/site/package.json @@ -0,0 +1,31 @@ +{ + "name": "@junctio/site", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "typecheck": "astro check" + }, + "dependencies": { + "@astrojs/markdown-remark": "^7.3.1", + "@astrojs/mdx": "^8.0.1", + "@astrojs/sitemap": "^3.7.4", + "@astrojs/vue": "^7.0.3", + "@lucide/vue": "^1.46.0", + "@tailwindcss/vite": "^4.3.3", + "astro": "^7.3.3", + "tailwindcss": "^4.3.3", + "unist-util-visit": "^5", + "vfile": "^6", + "vue": "^3.5.42" + }, + "devDependencies": { + "@astrojs/check": "^0.9.10", + "@types/hast": "^3", + "tw-animate-css": "^1.4.0", + "typescript": "^5.9.3" + } +} diff --git a/apps/site/public/favicon.svg b/apps/site/public/favicon.svg new file mode 100644 index 0000000..b79de8c --- /dev/null +++ b/apps/site/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/apps/site/public/image.jpeg b/apps/site/public/image.jpeg new file mode 100644 index 0000000..5ddcbb0 Binary files /dev/null and b/apps/site/public/image.jpeg differ diff --git a/apps/site/public/logo-dark.png b/apps/site/public/logo-dark.png new file mode 100644 index 0000000..1c4c165 Binary files /dev/null and b/apps/site/public/logo-dark.png differ diff --git a/apps/site/public/logo-light.png b/apps/site/public/logo-light.png new file mode 100644 index 0000000..6a02a6d Binary files /dev/null and b/apps/site/public/logo-light.png differ diff --git a/apps/site/src/components/CopyCommand.vue b/apps/site/src/components/CopyCommand.vue new file mode 100644 index 0000000..e06ca27 --- /dev/null +++ b/apps/site/src/components/CopyCommand.vue @@ -0,0 +1,34 @@ + + + diff --git a/apps/site/src/components/DocsLayout.astro b/apps/site/src/components/DocsLayout.astro new file mode 100644 index 0000000..3285bde --- /dev/null +++ b/apps/site/src/components/DocsLayout.astro @@ -0,0 +1,98 @@ +--- +import Base from "@/layouts/Base.astro"; +import { DOCS_NAV } from "@/lib/site"; +import { getCollection } from "astro:content"; + +interface Props { + title: string; + description: string; + current: string; + headings?: { depth: number; slug: string; text: string }[]; + editUrl?: string; +} + +const { title, description, current, headings = [], editUrl } = Astro.props; +const useCases = (await getCollection("useCases")).sort((a, b) => a.data.order - b.data.order); +const toc = headings.filter((heading) => heading.depth === 2); +--- + + +
+ +
+ + { + editUrl && ( +

+ + Edit this page on GitHub + +

+ ) + } +
+ +
+ diff --git a/apps/site/src/components/Footer.astro b/apps/site/src/components/Footer.astro new file mode 100644 index 0000000..7a68162 --- /dev/null +++ b/apps/site/src/components/Footer.astro @@ -0,0 +1,18 @@ +--- +import { SITE } from "@/lib/site"; +--- + + diff --git a/apps/site/src/components/GatewayFlow.vue b/apps/site/src/components/GatewayFlow.vue new file mode 100644 index 0000000..d7efd0f --- /dev/null +++ b/apps/site/src/components/GatewayFlow.vue @@ -0,0 +1,130 @@ + + + diff --git a/apps/site/src/components/GithubMark.astro b/apps/site/src/components/GithubMark.astro new file mode 100644 index 0000000..566f549 --- /dev/null +++ b/apps/site/src/components/GithubMark.astro @@ -0,0 +1,12 @@ +--- +interface Props { + class?: string; +} +const { class: className } = Astro.props; +--- + + diff --git a/apps/site/src/components/Header.astro b/apps/site/src/components/Header.astro new file mode 100644 index 0000000..a8bcc8d --- /dev/null +++ b/apps/site/src/components/Header.astro @@ -0,0 +1,48 @@ +--- +import GithubMark from "@/components/GithubMark.astro"; +import ThemeToggle from "@/components/ThemeToggle.vue"; +import { SITE } from "@/lib/site"; + +const path = Astro.url.pathname; +const links = [ + { href: "/docs/", label: "Docs" }, + { href: "/use-cases/", label: "Use cases" } +]; +--- + +
+
+ + + + {SITE.name} + + +
+ + + + +
+
+
diff --git a/apps/site/src/components/ThemeToggle.vue b/apps/site/src/components/ThemeToggle.vue new file mode 100644 index 0000000..81454ad --- /dev/null +++ b/apps/site/src/components/ThemeToggle.vue @@ -0,0 +1,30 @@ + + + diff --git a/apps/site/src/components/TokenTimeline.vue b/apps/site/src/components/TokenTimeline.vue new file mode 100644 index 0000000..219b614 --- /dev/null +++ b/apps/site/src/components/TokenTimeline.vue @@ -0,0 +1,75 @@ + + + diff --git a/apps/site/src/content.config.ts b/apps/site/src/content.config.ts new file mode 100644 index 0000000..b55e64c --- /dev/null +++ b/apps/site/src/content.config.ts @@ -0,0 +1,21 @@ +import { defineCollection } from "astro:content"; +import { z } from "astro/zod"; +import { glob } from "astro/loaders"; + +const docs = defineCollection({ + loader: glob({ pattern: "**/*.md", base: "../../docs" }), + schema: z.object({}).passthrough() +}); + +const useCases = defineCollection({ + loader: glob({ pattern: "**/*.mdx", base: "./src/content/use-cases" }), + schema: z.object({ + title: z.string(), + description: z.string(), + client: z.string(), + order: z.number(), + keywords: z.array(z.string()).default([]) + }) +}); + +export const collections = { docs, useCases }; diff --git a/apps/site/src/content/use-cases/claude-ai-connector.mdx b/apps/site/src/content/use-cases/claude-ai-connector.mdx new file mode 100644 index 0000000..296b1a4 --- /dev/null +++ b/apps/site/src/content/use-cases/claude-ai-connector.mdx @@ -0,0 +1,35 @@ +--- +title: A custom connector for claude.ai and Claude Desktop +description: Serve your own MCP servers to claude.ai and Claude Desktop through the built-in OAuth authorization server. +client: claude.ai +order: 3 +keywords: ["claude.ai custom connector self-hosted", "claude desktop mcp oauth", "mcp server dynamic client registration"] +--- + +claude.ai and Claude Desktop connect to remote MCP servers as custom connectors, and they cannot send an API key header. They need an OAuth authorization server that publishes RFC 8414 metadata, accepts RFC 7591 dynamic client registration and runs authorization code with PKCE. Junctio is that server, so you do not have to set up Keycloak or Auth0 to expose a stdio server on your VPS to claude.ai. + +## Prerequisites + +- The gateway behind TLS. [Caddy](/docs/caddy/) does it in two lines. OAuth does not work over plain HTTP. +- `JUNCTIO_BASE_URL` set to the public URL, `JUNCTIO_TRUST_PROXY=true` behind the proxy. + +## The setup + +1. Create an endpoint and switch its auth to `oauth` or `any`. +2. In claude.ai, add a custom connector with `https://mcp.example.com/mcp/main`. +3. The client registers itself, opens the authorization page, and stops at a consent screen that asks for the admin password. +4. Approve. From here on the client holds an access token that lives an hour and a refresh token that lives thirty days and rotates on every use. + +The discovery documents under `/.well-known/` are served only for endpoints whose auth mode includes OAuth. An API-key-only endpoint answers 404 there, so nothing is advertised that you did not switch on. + +## Revoking a client + +Every registered client is listed in Settings. Revoke it and its tokens die with it. Registering a client grants nothing on its own; the consent screen is the only way in. + +## Using your own identity provider + +If you already run Keycloak, Authentik or Auth0, set `JUNCTIO_OAUTH_ISSUER` and the gateway validates that provider's JWTs instead of issuing its own. Note that the [management MCP](/docs/management-mcp/) is then reachable only with the admin token, because an external provider cannot ask for your admin password. + +## Related + +The authentication section of [Endpoints](/docs/endpoints/) has the full token lifetimes and the header the gateway sends on a 401. diff --git a/apps/site/src/content/use-cases/claude-code.mdx b/apps/site/src/content/use-cases/claude-code.mdx new file mode 100644 index 0000000..90daff1 --- /dev/null +++ b/apps/site/src/content/use-cases/claude-code.mdx @@ -0,0 +1,34 @@ +--- +title: One MCP endpoint for Claude Code +description: Replace a dozen server entries in Claude Code with one HTTP endpoint that Junctio keeps authenticated. +client: Claude Code +order: 1 +keywords: ["claude code mcp gateway", "claude mcp add http", "claude code mcp oauth refresh"] +--- + +Claude Code reads its MCP servers from a config it manages with `claude mcp add`. Every stdio server there is a process started by the editor, with its credentials in plain environment variables, and every remote server is a token that Claude Code refreshes on its own schedule, which is usually after something fails. With a gateway in between, Claude Code knows one URL and one key. + +## The setup + +Add your servers to Junctio, put them in a namespace, create an endpoint with `api_key` auth and issue a key. Then: + +```bash +claude mcp add --transport http junctio https://mcp.example.com/mcp/main \ + --header "Authorization: Bearer jn_..." +``` + +That is the whole client side. Tools show up as `__`, so `github__create_issue` and `linear__create_issue` do not collide, and the endpoint's `instructions` field carries the namespace description plus whatever each upstream announced on connect, so Claude reads one coherent brief before calling anything. + +## What changes day to day + +- Adding or removing a server happens in the gateway UI, or through the [management MCP](/docs/management-mcp/) from inside Claude Code itself. The client config never changes. +- An upstream that needs OAuth, GitHub or Linear for instance, is authorized once in the gateway. The token is [refreshed before it expires](/docs/upstream-servers/), so a long session never hits a 401. +- One machine or three: the same key works from a laptop and a dev container, and the servers run where the gateway runs. + +## Locally, without a domain + +`api_key` auth works over plain HTTP, so `http://localhost:3000/mcp/main` is fine on one machine. OAuth is the only mode that needs TLS. + +## Related + +[Endpoints](/docs/endpoints/) explains aggregation and the auth modes. [Upstream servers](/docs/upstream-servers/) covers stdio runtimes, remote servers and upstream OAuth. diff --git a/apps/site/src/content/use-cases/cursor.mdx b/apps/site/src/content/use-cases/cursor.mdx new file mode 100644 index 0000000..5c19418 --- /dev/null +++ b/apps/site/src/content/use-cases/cursor.mdx @@ -0,0 +1,40 @@ +--- +title: Cursor with a self-hosted MCP gateway +description: Point Cursor at one Streamable HTTP endpoint and stop managing per-project mcp.json files. +client: Cursor +order: 2 +keywords: ["cursor mcp gateway", "cursor mcp.json http server", "cursor mcp api key"] +--- + +Cursor reads `.cursor/mcp.json` per project and a global one in your home directory. Each file lists its own servers, so a change means editing every copy. With Junctio the file contains one entry. + +## The setup + +Create an endpoint in the gateway with `api_key` auth and issue a key, then write: + +```json +{ + "mcpServers": { + "junctio": { + "url": "https://mcp.example.com/mcp/main", + "headers": { + "Authorization": "Bearer jn_..." + } + } + } +} +``` + +Junctio speaks Streamable HTTP and falls back to SSE for clients that ask for it, so the same URL keeps working across Cursor versions. + +## Per-project namespaces + +A namespace is the unit Cursor sees. Give the frontend repo a namespace with the design system server and Playwright, give the backend one with Postgres and the deployment tools, and issue one endpoint per namespace. The key can be bound to a single endpoint, so a leaked project key cannot reach the other namespace. + +## Rate limits and the request log + +Cursor's agent mode can fan out tool calls quickly. An endpoint can cap requests per minute, counted per key, and answers over the limit with a 429 and `Retry-After`. The request log in the gateway shows every call with the upstream it went to and how long it took. + +## Related + +[Endpoints](/docs/endpoints/) for the protocol details, [Explore](/docs/explore/) to import the servers you already have from a pasted `mcp.json`. diff --git a/apps/site/src/content/use-cases/small-team.mdx b/apps/site/src/content/use-cases/small-team.mdx new file mode 100644 index 0000000..8187d5d --- /dev/null +++ b/apps/site/src/content/use-cases/small-team.mdx @@ -0,0 +1,28 @@ +--- +title: Shared MCP servers for a small team +description: One gateway, one namespace per role, one key per person. No shared secrets in client configs. +client: Team +order: 4 +keywords: ["mcp gateway for teams", "share mcp servers across team", "mcp server access control api key"] +--- + +Three to ten people, a few upstream servers that hold real credentials, and every laptop with its own copy of every token: that is the default MCP setup, and it is the part that a gateway removes. + +## The shape of it + +- The gateway runs on a small VPS or inside the team's existing Docker host, behind [Caddy](/docs/caddy/) or the platform's own proxy. +- Upstream servers are configured once. The credentials they need, a database URL, a GitHub token, live in the gateway, encrypted at rest with `JUNCTIO_SECRET`, and are never handed to a client. +- Namespaces group servers by role. `backend` gets Postgres and the deployment tools, `docs` gets the wiki and the design system, `everyone` gets search and the issue tracker. +- One endpoint per namespace, one API key per person, each key bound to a single endpoint. Only an argon2id hash of a key is stored and the key is shown once. + +## Onboarding and offboarding + +A new teammate gets one key and one snippet; their client config is a single entry. When they leave, revoking the key is the whole offboarding, and the upstream credentials they used never existed on their machine. + +## Keeping an eye on it + +The request log records every call with its key, upstream and duration. Rate limits are per key, so one runaway agent cannot exhaust an upstream's quota for everyone. Turn on the [security audit](/docs/security-audit/) and the gateway checks every stdio server's dependency tree on a schedule, with a per-severity action you choose, from listing the finding to quarantining the server. + +## Letting an agent do the admin + +Switch on the [management MCP](/docs/management-mcp/) and the person who maintains the gateway can add servers, adjust namespaces and read logs from their own Claude Code session, authenticated with `JUNCTIO_ADMIN_TOKEN`. API keys cannot be issued through it and consent cannot be granted, so an agent cannot widen its own access. diff --git a/apps/site/src/content/use-cases/vps-caddy.mdx b/apps/site/src/content/use-cases/vps-caddy.mdx new file mode 100644 index 0000000..29ea0d6 --- /dev/null +++ b/apps/site/src/content/use-cases/vps-caddy.mdx @@ -0,0 +1,41 @@ +--- +title: Junctio on a VPS behind Caddy +description: Run the gateway on your own server with automatic TLS, a persistent volume and OAuth that works. +client: Self-hosted +order: 5 +keywords: ["self-hosted mcp gateway docker", "mcp server behind caddy", "mcp gateway vps"] +--- + +The image is `ghcr.io/k2so-dev/junctio`, built for amd64 and arm64. A single small VPS runs it comfortably alongside Caddy. + +## Compose + +```bash +mkdir junctio && cd junctio +curl -fsSLO https://raw.githubusercontent.com/k2so-dev/junctio/main/compose.yml +curl -fsSL https://raw.githubusercontent.com/k2so-dev/junctio/main/.env.example -o .env +``` + +In `.env` set `JUNCTIO_SECRET` to the output of `openssl rand -hex 32`, `JUNCTIO_BASE_URL` to `https://mcp.example.com` and `JUNCTIO_TRUST_PROXY=true`. Then `docker compose up -d`. The database lives in the `/data` volume, and [CHANGELOG.md](https://github.com/k2so-dev/junctio/blob/main/CHANGELOG.md) says when a release needs a fresh one. + +## Caddy + +```caddyfile +mcp.example.com { + reverse_proxy localhost:3000 +} +``` + +Caddy obtains and renews the certificate on its own. The base URL must match what clients type: redirect URIs, the expected JWT audience and the resource metadata documents are all derived from it. + +## What the volume holds + +`junctio.db` with servers, namespaces, endpoints, hashed API keys, encrypted upstream tokens and the request log. Back up the volume and `JUNCTIO_SECRET` together; the database is useless without the key. + +## Servers that ship as an image + +Mount the Docker socket, or point `JUNCTIO_DOCKER_SOCKET` at it, and the `docker` runtime starts upstream servers as sibling containers. [Upstream servers](/docs/upstream-servers/) has the details and the reason the image sets `TMPDIR` to an executable directory. + +## Managed platforms + +Prefer a panel over a shell? [Dokploy](/docs/deploy/dokploy/), [Coolify](/docs/deploy/coolify/) and [Portainer](/docs/deploy/portainer/) each have a page with the exact compose to paste. diff --git a/apps/site/src/env.d.ts b/apps/site/src/env.d.ts new file mode 100644 index 0000000..f964fe0 --- /dev/null +++ b/apps/site/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/site/src/layouts/Base.astro b/apps/site/src/layouts/Base.astro new file mode 100644 index 0000000..15e5c97 --- /dev/null +++ b/apps/site/src/layouts/Base.astro @@ -0,0 +1,65 @@ +--- +import "@/styles/global.css"; +import { SITE } from "@/lib/site"; +import Header from "@/components/Header.astro"; +import Footer from "@/components/Footer.astro"; + +interface Props { + title: string; + description?: string; + ogType?: "website" | "article"; + jsonLd?: Record; +} + +const { title, description = SITE.description, ogType = "website", jsonLd } = Astro.props; +const canonical = new URL(Astro.url.pathname, Astro.site); +const fullTitle = title === SITE.name ? `${SITE.name}: ${SITE.tagline}` : `${title} · ${SITE.name}`; +const ogImage = new URL("/image.jpeg", Astro.site); +--- + + + + + + + {fullTitle} + + + + + + + + + + + + + + + + + + + {jsonLd && + + +
+
+ +
+