11 Commits
Author SHA1 Message Date
jknapp 87475e60d0 Merge pull request 'fix: serve RFC 9728 path-suffixed metadata, document connector redirect URIs' (#26) from fix/oauth-redirect-uris-and-rfc9728-discovery into main 2026-09-16 23:33:06 +00:00
shadowdaoandClaude Opus 5 3be9135aee docs: correct the segment-decoding comment on the catch-all route
Review caught the stated invariant being false. Next splits the matched
suffix on literal `/` before decoding each piece, so a segment can be empty
(`api//mcp`) and a single segment can carry a decoded slash (`api%2Fmcp`).
Neither reaches the allowlist — both fail closed — but the comment claimed
an invariant the router does not provide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:32:46 -07:00
shadowdaoandClaude Opus 5 f46f54d50b docs: quote the loopback regex actually deployed on the provider
The pattern in the walkthrough was written from memory and differs from the
one on the `shared-memory-mcp` provider in a way that matters: `…:[0-9]+/.*`
requires a path component, so it fails to match a bare
`http://localhost:5693`, which the deployed `…:\d+(/.*)?$` accepts. Copy the
live value verbatim instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:11:08 -07:00
shadowdaoandClaude Opus 5 29f5673eac fix: serve RFC 9728 path-suffixed metadata, document connector redirect URIs
Two separate discovery footguns, both found while debugging an Authentik
"Redirect URI Error" on a claude.ai custom connector.

RFC 9728 §3.1 puts the metadata for a resource identified by
`https://host/api/mcp` at `/.well-known/oauth-protected-resource/api/mcp`.
Only the root form was served, so clients that derive the metadata URL from
the MCP endpoint URL — rather than reading `resource_metadata` off our 401 —
got Next.js's HTML 404 and failed discovery with a JSON parse error.

Add a `[...path]` route serving the same document with `resource` naming the
suffixed identifier (§3.3 has the client compare it as an exact string, so
echoing the bare origin would be rejected). The document body moves to
`lib/auth/resource-metadata.ts` so the two routes cannot drift apart on
`scopes_supported` — a divergence there costs you the `aud` claim or the
refresh token. Paths are allowlisted rather than wildcarded so this cannot
advertise resources the app does not serve. `buildWwwAuthenticate()` still
points at the root URL; this change is purely additive.

Separately, the redirect URIs an MCP provider needs depend on how clients
reach it: a loopback URI for the CLI, `https://claude.ai/api/mcp/auth_callback`
for a claude.ai custom connector. Registering only the former is what produces
the "Redirect URI Error" page, and a portless `http://localhost/callback`
entry matches nothing the CLI sends. Document both, keyed on the literal
error text, and note that DCR is enterprise-gated on Authentik so these are
hand-registered on a FOSS instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:08:06 -07:00
jknapp c68d72857f Merge pull request 'docs: note that Entra keys identity on oid, not sub' (#25) from docs/identity-keying-note into main 2026-08-13 02:40:44 +00:00
shadowdaoandClaude Opus 5 391d8e0360 docs: note that Entra keys identity on oid, not sub
The Architecture section still described identity as keyed on `sub` + `iss`,
which stopped being universally true when 0005_user_oid.sql landed. It is
still correct for Authentik, Keycloak and Okta — but on Entra, `sub` is
pairwise per app registration and `oid` is the key. Someone reading only
this section would draw exactly the wrong conclusion about why a second
account appeared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:40:31 -07:00
jknapp bd7a1ca59a Merge pull request 'feat: adopt the rebalanced mark and add a wordmark lockup' (#24) from feat/rebalanced-mark-and-lockup into main 2026-08-13 01:46:31 +00:00
jknapp a3f28c52a5 Merge pull request 'feat: support Microsoft Entra ID as an OIDC provider' (#22) from feat/entra-id-support into main 2026-08-13 01:46:29 +00:00
jknapp 423a3fa7fe Merge pull request 'docs: draft ten logo concepts' (#23) from docs/logo-concepts into main 2026-08-13 01:46:26 +00:00
shadowdaoandClaude Opus 5 5d06095883 feat: adopt the rebalanced mark and add a wordmark lockup
The shipped mark failed the one size it most needed to survive. At 16px
in a browser tab its outer strokes rasterised away entirely and the
2.5px gap between stroke and node filled in, so the favicon rendered as
an indeterminate horizontal smear rather than as anything. Three changes
fix it, all driven by that case and verified by rendering rather than by
reasoning: strokes stop at x=30 so the gap survives, outer opacity goes
.45/.55 -> .7 so the neighbours stay visible, and the node grows to
r=8.5 so it still reads as a disc.

This is not a rebrand. The mark is the same three-signals-converging
figure and keeps whatever recognition it has earned; only its
proportions changed. icon.svg and logo.svg had drifted slightly apart
(different node radii and stroke ends) and are now geometrically
identical, differing only in palette.

Adds the lockup, which had no asset at all, in two variants because one
cannot serve both jobs: public/lockup.svg uses currentColor for in-app
use, and docs/assets/lockup.svg fixes the colour to #0092fd for the
README. An <img src> reference resolves currentColor to black, so the
currentColor variant would be invisible against a dark README. The
wordmark carries textLength, without which it overruns the viewBox on
wider monospace fonts.

Concepts and the reasoning behind the ones not chosen are on the
docs/logo-concepts branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:09:56 -07:00
shadowdaoandClaude Opus 5 2c127886ff docs: draft ten logo concepts
Eight fresh directions plus two refinements of the shipped mark, each
authored as a real currentColor SVG in the app's house style rather than
as a description, so any of them can be dropped straight in and the
favicon tile is a mechanical derivation.

Every mark was rendered at 120/32/24/16px and looked at, which changed
the set three times: the braid came out as a plain multiplication sign
and was rebuilt with actual over/under crossings, the anchor was
rebalanced to three-fold symmetry, and the first return-arc read even
more like a copyright glyph than the current one.

The finding that matters is not one of the eight. The mark shipping
today fails at 16px — its outer strokes drop out to rasterisation and
the 2.5px gap to the node fills in, so the favicon renders as a smear.
Concept 09 fixes that in five lines and is worth taking regardless of
whether anything here replaces the mark.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 10:51:47 -07:00
22 changed files with 668 additions and 51 deletions
+87 -7
View File
@@ -1,4 +1,4 @@
# shared-memory
# <img src="docs/assets/lockup.svg" alt="shared-memory" width="283">
A self-hosted MCP server that gives Claude Code sessions a **shared, persistent
memory** plus a **reusable snippet library**, behind your own OIDC login.
@@ -37,8 +37,10 @@ Workspace. Anything that publishes a `/.well-known/openid-configuration`.
The same container serves both the MCP endpoint (under `/api/mcp`) and the
Web UI. Users authenticate via your OIDC provider with pre-registered
confidential clients. Identity is keyed on the OIDC `sub` + `iss` so
memories are scoped per user.
confidential clients. Identity is keyed on the OIDC `iss` + `sub` so memories
are scoped per user — except on Microsoft Entra ID, where `sub` is pairwise
(a different value per app registration for the same person) and `oid` is
used instead. See [`docs/oidc-entra-id.md`](docs/oidc-entra-id.md) §7.
---
@@ -296,12 +298,75 @@ tokens carry `aud: shared-memory` (or whatever value you chose).
or `Confidential` if you prefer to issue a secret to each Claude Code
install — both work. Phase 1 expects Public.
- **Client ID:** auto-generated → copy to `.env` as `OIDC_CLIENT_ID_MCP`
- **Redirect URIs:** Claude Code prints the exact value when it first
connects to the MCP endpoint. Paste it into Authentik then.
- **Redirect URIs:** more than one, and which ones depends on how people
reach the server — see **Which redirect URIs to register** below.
- **Scopes:** `openid`, `profile`, `email` (plus `offline_access` — see
**Keeping sessions alive** below)
- **Signing Key:** same cert as the Web provider
#### Which redirect URIs to register
The MCP provider is reached by clients of two different shapes — one running on
your machine, one running inside claude.ai — and they come back from the IdP at
**different** redirect URIs. Register every one you intend to use, before the
first connection attempt:
- **Claude Code CLI, including this repo's plugin** — the CLI catches the
callback on a loopback listener, so the URI is
`http://localhost:<port>/callback`, where `<port>` is whatever
`--callback-port` (or the plugin's `callbackPort`) is set to. Set the entry's
matching mode to **Regex** so any port works without re-registering:
```
^http://(127\.0\.0\.1|localhost):\d+(/.*)?$
```
**The port is not optional.** Authentik rejects a portless
`http://localhost/callback`, so an entry copied from the Entra ID walkthrough
— where the port component is ignored on purpose, see
[docs/oidc-entra-id.md](docs/oidc-entra-id.md) — matches nothing the CLI ever
sends.
- **A claude.ai custom connector** — the server added through claude.ai's web
UI rather than installed locally. claude.ai brokers the OAuth flow, so the
browser never returns to your machine and the loopback entries above are
irrelevant. Register the exact string:
```
https://claude.ai/api/mcp/auth_callback
```
- **The manual-paste fallback** (*C. Manual-paste fallback* below) — that
callback is hosted by *this* server, not by the client:
```
https://memory.example.com/auth/cli-callback
```
> **A missing entry shows up as Authentik's "Redirect URI Error" page** — *"The
> request fails due to a missing, invalid, or mismatching redirection URI
> (redirect_uri)"* — served **after** the client sends you to Authentik but
> **before** any login or consent screen. Nothing in the client says which URI
> was rejected, so it reads as "the connector is broken" when the provider
> simply has no entry matching what was sent. Adding the server as a claude.ai
> connector without the `https://claude.ai/api/mcp/auth_callback` entry is the
> common way to land here.
>
> **Nothing registers these for you — on a FOSS instance.** Authentik *does*
> implement RFC 7591 Dynamic Client Registration
> ([goauthentik/authentik#8751](https://github.com/goauthentik/authentik/issues/8751),
> closed July 2026), but gated behind an **enterprise** licence; a maintainer
> has since said it will move to the open-source build. Until it does, a FOSS
> instance advertises no `registration_endpoint` at all. Check yours:
>
> ```
> curl -s "${OIDC_ISSUER_MCP}.well-known/openid-configuration" | jq .registration_endpoint
> ```
>
> `null` means no client — CLI or claude.ai — can add its own redirect URI, so
> every URI above is typed into the provider by hand. (Same gap as **Why no
> zero-config plugin yet** below.)
#### Keeping sessions alive (`offline_access`)
Without this, a connected MCP client gets an access token and **no refresh
@@ -411,6 +476,13 @@ prompt, never reaching the app.
Three paths, in order of preference:
> **Connecting from claude.ai instead?** A server added there as a *custom
> connector* needs nothing on your machine, but its OAuth callback is
> `https://claude.ai/api/mcp/auth_callback`, not a loopback URI. Register it on
> the MCP provider first (**Which redirect URIs to register** above) or the
> connector stops at Authentik's *Redirect URI Error* page before you ever see
> a login prompt.
### A. Plugin (recommended — one command, no flags to remember)
This repo doubles as a Claude Code plugin marketplace. `plugin/.mcp.json` ships a
@@ -499,8 +571,8 @@ What happens:
`--callback-port` is required because your IdP only accepts pre-registered
redirect URIs. Pick any free port; just make sure the matching URI is in
your MCP client's **Redirect URIs** list. Authentik users with the regex
pattern from the setup step (`^http://(127\.0\.0\.1|localhost):\d+/.*$`)
can use any port without re-registering.
entry from the setup step (`^http://(127\.0\.0\.1|localhost):\d+(/.*)?$`) can
use any port without re-registering.
### C. Manual-paste fallback (when loopback isn't reachable)
@@ -723,6 +795,14 @@ reopen a closed question.
scope mapping; on EntraID it's the API "Application ID URI"; on Keycloak
it's a client-scope audience mapper. See **Setting the `aud` claim** above
for the Authentik recipe; other IdPs need the equivalent in their UI.
- **Authentik shows "Redirect URI Error — The request fails due to a missing,
invalid, or mismatching redirection URI (redirect_uri)"** — the redirect URI
the client sent is not registered on the `shared-memory-mcp` provider. From a
claude.ai custom connector the missing entry is
`https://claude.ai/api/mcp/auth_callback`; from the CLI it's the loopback URI
for your `--callback-port`, and a portless `http://localhost/callback` entry
will not match it. Authentik has no Dynamic Client Registration, so no client
can add the URI itself — see **Which redirect URIs to register** above.
- **Auth.js callback fails with `OAUTH_CALLBACK_ERROR`** — your `PUBLIC_URL`
doesn't match the redirect URI your IdP is configured with. They must be
exactly equal, scheme and trailing slash included.
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, test, vi } from "vitest";
/**
* RFC 9728 §3.1 puts the metadata for `https://host/api/mcp` at
* `https://host/.well-known/oauth-protected-resource/api/mcp`. MCP clients
* that derive that URL from the endpoint URL — instead of following the
* `resource_metadata` parameter on our 401 — used to receive the Next.js 404
* HTML page here, so discovery died on a JSON parse error.
*
* Two things therefore have to hold, and both are easy to break silently:
* the document must name the SUFFIXED resource (§3.3 has the client reject a
* document whose `resource` isn't the identifier it asked about), and it must
* stay byte-for-byte in step with the root document's scope list, because a
* scope missing from whichever document a given client reads is a scope that
* client will never request.
*/
type Metadata = { resource: string; scopes_supported: string[] };
async function fetchSuffixed(
segments: string[],
): Promise<{ status: number; body: Metadata }> {
vi.resetModules();
const { GET } = await import(
"@/app/.well-known/oauth-protected-resource/[...path]/route"
);
const res = await GET(new Request("http://localhost/ignored"), {
params: Promise.resolve({ path: segments }),
});
return { status: res.status, body: (await res.json()) as Metadata };
}
async function fetchRoot(): Promise<Metadata> {
vi.resetModules();
const { GET } = await import("@/app/.well-known/oauth-protected-resource/route");
return (await GET().json()) as Metadata;
}
afterEach(() => {
delete process.env.OIDC_OFFLINE_ACCESS;
delete process.env.OIDC_AUDIENCE_SCOPE;
});
describe("path-suffixed oauth-protected-resource metadata", () => {
test("serves the MCP endpoint's document with the suffixed resource identifier", async () => {
const { status, body } = await fetchSuffixed(["api", "mcp"]);
expect(status).toBe(200);
// §3.3: a strict client compares this against the identifier it asked
// about, so the bare origin would get the whole document rejected.
expect(body.resource).toBe("http://localhost:3000/api/mcp");
});
test("404s for a path this app does not serve, rather than advertising it", async () => {
// The allowlist exists so we never claim that arbitrary paths are
// OAuth-protected resources of this deployment.
const { status } = await fetchSuffixed(["api", "not-mcp"]);
expect(status).toBe(404);
});
test("advertises exactly the scopes the root document does", async () => {
// Regression guard against the two documents drifting apart: the audience
// scope is what makes `aud` appear on the token at all, and a client that
// discovered us through the suffixed URL would never request a scope that
// only the root document lists.
process.env.OIDC_OFFLINE_ACCESS = "true";
const root = await fetchRoot();
const { body: suffixed } = await fetchSuffixed(["api", "mcp"]);
expect(suffixed.scopes_supported).toEqual(root.scopes_supported);
expect(suffixed.scopes_supported).toContain("aud-test-audience");
expect(suffixed.scopes_supported).toContain("offline_access");
});
test("honours an explicit audience scope name, like the root document", async () => {
process.env.OIDC_AUDIENCE_SCOPE = "custom-aud-scope";
const root = await fetchRoot();
const { body: suffixed } = await fetchSuffixed(["api", "mcp"]);
expect(suffixed.scopes_supported).toContain("custom-aud-scope");
expect(suffixed.scopes_supported).toEqual(root.scopes_supported);
});
});
@@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import { buildResourceMetadata, publicOrigin } from "@/lib/auth/resource-metadata";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Resource paths this deployment will publish metadata for.
*
* An allowlist rather than a wildcard, for two reasons. RFC 9728 §3.1 maps a
* metadata URL to one specific protected resource, so answering for arbitrary
* paths would advertise resources this app does not serve — a client could
* "discover" `https://host/anything` as an OAuth-protected resource and be
* told, wrongly, that tokens for it are obtainable from our IdP. And every
* path that answers is surface: a wildcard turns this into an open reflector
* that echoes attacker-chosen path segments back inside a JSON document.
*
* `api/mcp` is the only MCP endpoint here (app/api/mcp/route.ts). Add an
* entry when a second one ships — not before.
*/
const METADATA_RESOURCE_PATHS: ReadonlySet<string> = new Set(["api/mcp"]);
/**
* RFC 9728 §3.1 — path-suffixed protected resource metadata.
*
* For a resource identified by `https://host/api/mcp`, the spec puts its
* metadata at `https://host/.well-known/oauth-protected-resource/api/mcp`:
* the resource's path is appended to the well-known path. Clients that derive
* the metadata URL from the MCP endpoint URL — rather than reading
* `resource_metadata` off our 401's `WWW-Authenticate` header — probe that URL
* first, and before this route existed they got Next.js's 404 HTML page, which
* fails discovery with a JSON parse error rather than anything diagnosable.
*
* The document is identical to the root one except for `resource`, which must
* name the suffixed identifier: §3.3 requires the client to check that the
* returned `resource` equals the identifier it asked about, so echoing the
* bare origin here would make a strict client reject the document outright.
*/
export async function GET(
_req: Request,
ctx: { params: Promise<{ path: string[] }> },
): Promise<NextResponse> {
const { path } = await ctx.params;
// Next splits the matched suffix on literal `/` and only then decodes each
// piece, so a segment can be empty (`api//mcp` -> ["api","","mcp"]) and a
// single segment can itself contain a decoded slash (`api%2Fmcp` -> one
// element, "api/mcp"). Join and compare on the same normalized form the
// allowlist is written in, and let anything else fail closed.
const resourcePath = path.join("/");
if (!METADATA_RESOURCE_PATHS.has(resourcePath)) {
// JSON, not the HTML 404 page, so a client that probes a wrong path gets
// a parseable answer instead of the failure mode this route exists to fix.
return NextResponse.json(
{ error: "not_found", error_description: "no such protected resource" },
{ status: 404 },
);
}
return NextResponse.json(
buildResourceMetadata(`${publicOrigin()}/${resourcePath}`),
);
}
@@ -1,6 +1,5 @@
import { NextResponse } from "next/server";
import { env } from "@/lib/env";
import { mcpIssuer } from "@/lib/auth/jwt";
import { buildResourceMetadata, publicOrigin } from "@/lib/auth/resource-metadata";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -10,36 +9,12 @@ export const dynamic = "force-dynamic";
*
* MCP clients discover the authorization server (Authentik) via this
* endpoint after receiving a 401 with `WWW-Authenticate: resource_metadata=...`.
*
* This is the root form of the document, describing the deployment origin as
* the protected resource. Clients that derive the metadata URL from the MCP
* endpoint URL instead of following the header land on the path-suffixed form
* (§3.1) served by the sibling `[...path]` route.
*/
export function GET() {
const resource = env().PUBLIC_URL.replace(/\/$/, "");
// The audience scope MUST be advertised. Authentik only evaluates a scope
// mapping when the client requests that scope by name, and the client only
// learns scope names from this document. Omit it and every access token
// arrives without `aud`, which jwt.ts rejects as "claim invalid: aud".
const audienceScope =
env().OIDC_AUDIENCE_SCOPE ?? `aud-${env().OIDC_AUDIENCE}`;
// Same mechanism as the audience scope, different consequence: a client
// only requests `offline_access` if it sees the name here, and without
// that request the IdP returns no refresh token — so the client cannot
// renew and the user gets kicked back to an interactive login whenever
// the access token expires.
//
// Opt-in, because the IdP needs a matching scope mapping; advertising one
// it doesn't offer can fail the whole authorization request.
const scopes = ["openid", "profile", "email", audienceScope];
if (env().OIDC_OFFLINE_ACCESS) scopes.push("offline_access");
return NextResponse.json({
resource,
// The MCP application's issuer, which is not necessarily the Web UI's —
// see mcpIssuer(). Advertising the wrong one sends clients to a discovery
// document whose tokens this endpoint will then reject on `iss`.
authorization_servers: [mcpIssuer()], // as configured, slash and all
scopes_supported: scopes,
bearer_methods_supported: ["header"],
resource_documentation: `${resource}/`,
});
return NextResponse.json(buildResourceMetadata(publicOrigin()));
}
+10 -5
View File
@@ -5,12 +5,17 @@
Three retrieval signals - vector, full-text, tags - converging on a single
memory. The direct match runs straight through at full strength; the two
ranked neighbours fall back, which is the fusion the search actually does.
Opacity is held equal on the outer pair so the mark stays balanced at 16px.
Every proportion here is set by the 16px browser-tab case. The outer pair
is held at .7 (not .55) because below that it rasterises away entirely,
the strokes stop at x=30 so the gap to the node doesn't fill in, and the
node is r=8.5 so it still reads as a disc. Geometry is identical to
public/logo.svg; only the palette differs.
-->
<g fill="none" stroke-linecap="round" stroke-width="7">
<path d="M13 15C25 15 26 32 35 32" stroke="#0092fd" opacity=".55"/>
<path d="M13 32H35" stroke="#49a9ff"/>
<path d="M13 49C25 49 26 32 35 32" stroke="#0092fd" opacity=".55"/>
<path d="M12 16C24 16 25 32 30 32" stroke="#0092fd" opacity=".7"/>
<path d="M12 32H30" stroke="#49a9ff"/>
<path d="M12 48C24 48 25 32 30 32" stroke="#0092fd" opacity=".7"/>
</g>
<circle cx="45" cy="32" r="7.5" fill="#76c0ff"/>
<circle cx="46" cy="32" r="8.5" fill="#76c0ff"/>
</svg>

Before

Width:  |  Height:  |  Size: 847 B

After

Width:  |  Height:  |  Size: 1.1 KiB

+73
View File
@@ -0,0 +1,73 @@
import { env } from "@/lib/env";
import { mcpIssuer } from "@/lib/auth/jwt";
/**
* The protected-resource metadata document, RFC 9728 §2.
*
* Shared by both metadata routes — the root `/.well-known/oauth-protected-
* resource` and the path-suffixed `/.well-known/oauth-protected-resource/
* <resource path>` form of §3.1 — because the two documents differ ONLY in
* the `resource` identifier they describe. Anything else drifting between
* them is a bug: a client that discovers us through the suffixed URL would
* be told to request a different scope set than one that follows the
* `WWW-Authenticate: resource_metadata=...` header, and whichever of the two
* lost the audience scope would hand back tokens with no `aud` claim.
*/
export interface ResourceMetadata {
resource: string;
authorization_servers: string[];
scopes_supported: string[];
bearer_methods_supported: string[];
resource_documentation: string;
}
/**
* The public origin, with any trailing slash stripped.
*
* `resource` values are compared as exact strings by clients (RFC 9728 §3.3),
* so `https://host/` and `https://host` are not interchangeable — PUBLIC_URL
* is written both ways in the wild and only the stripped form is emitted.
*/
export function publicOrigin(): string {
return env().PUBLIC_URL.replace(/\/$/, "");
}
/**
* Build the metadata document for `resource`.
*
* The caller supplies the resource identifier because it depends on which
* URL the document was fetched from; everything else is deployment config.
*/
export function buildResourceMetadata(resource: string): ResourceMetadata {
// The audience scope MUST be advertised. Authentik only evaluates a scope
// mapping when the client requests that scope by name, and the client only
// learns scope names from this document. Omit it and every access token
// arrives without `aud`, which jwt.ts rejects as "claim invalid: aud".
const audienceScope =
env().OIDC_AUDIENCE_SCOPE ?? `aud-${env().OIDC_AUDIENCE}`;
// Same mechanism as the audience scope, different consequence: a client
// only requests `offline_access` if it sees the name here, and without
// that request the IdP returns no refresh token — so the client cannot
// renew and the user gets kicked back to an interactive login whenever
// the access token expires.
//
// Opt-in, because the IdP needs a matching scope mapping; advertising one
// it doesn't offer can fail the whole authorization request.
const scopes = ["openid", "profile", "email", audienceScope];
if (env().OIDC_OFFLINE_ACCESS) scopes.push("offline_access");
return {
resource,
// The MCP application's issuer, which is not necessarily the Web UI's —
// see mcpIssuer(). Advertising the wrong one sends clients to a discovery
// document whose tokens this endpoint will then reject on `iss`.
authorization_servers: [mcpIssuer()], // as configured, slash and all
scopes_supported: scopes,
bearer_methods_supported: ["header"],
// Documentation lives at the site root regardless of which resource this
// document describes, so it is always derived from the public origin and
// not from `resource`.
resource_documentation: `${publicOrigin()}/`,
};
}
+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 283 64" width="283" height="64" role="img" aria-label="shared-memory">
<title>shared-memory</title>
<!--
Horizontal lockup for headers, docs, and the OAuth consent screen, where a
bare 64px glyph is too little and the full app header is too much.
currentColor, so it inherits the surrounding text colour. That does NOT
survive being referenced as <img src>, which resolves currentColor to
black - use docs/assets/lockup.svg for anything outside the app.
`textLength` is not decoration: the wordmark is set in whatever monospace
the viewer has, and without a locked advance width the text overruns the
viewBox on wider fonts.
-->
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="7">
<path d="M12 16C24 16 25 32 30 32" opacity=".7"/>
<path d="M12 32H30"/>
<path d="M12 48C24 48 25 32 30 32" opacity=".7"/>
</g>
<circle cx="46" cy="32" r="8.5" fill="currentColor"/>
<text x="72" y="41" fill="currentColor" textLength="203" lengthAdjust="spacingAndGlyphs"
font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
font-size="26" letter-spacing="-0.8">shared<tspan opacity=".45">-</tspan>memory</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+13 -5
View File
@@ -3,12 +3,20 @@
<!--
Transparent, currentColor variant of the mark for in-app use - inherits
the surrounding text color so it works on any surface. The tile version
used as the favicon lives at app/icon.svg.
used as the favicon lives at app/icon.svg, and the horizontal lockup at
public/lockup.svg.
Three retrieval signals converging on a single memory. Proportions are
set by the 16px case, which is the one that breaks: the strokes stop at
x=30 so the gap to the node survives rasterisation, the outer pair sits
at .7 rather than .45 so it doesn't drop out, and the node is r=8.5.
The earlier proportions rendered as an indeterminate smear in a browser
tab.
-->
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="7">
<path d="M13 15C25 15 27 32 37 32" opacity=".45"/>
<path d="M13 32H37"/>
<path d="M13 49C25 49 27 32 37 32" opacity=".7"/>
<path d="M12 16C24 16 25 32 30 32" opacity=".7"/>
<path d="M12 32H30"/>
<path d="M12 48C24 48 25 32 30 32" opacity=".7"/>
</g>
<circle cx="43" cy="32" r="8" fill="currentColor"/>
<circle cx="46" cy="32" r="8.5" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 648 B

After

Width:  |  Height:  |  Size: 1.1 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 283 64" width="283" height="64" role="img" aria-label="shared-memory">
<title>shared-memory</title>
<!--
Horizontal lockup for headers, docs, and the OAuth consent screen, where a
bare 64px glyph is too little and the full app header is too much.
Fixed-colour twin of apps/web/public/lockup.svg, for README and any other
context that references the file as <img src> - there currentColor resolves
to black and vanishes on a dark page. #0092fd holds on both git-host themes.
`textLength` is not decoration: the wordmark is set in whatever monospace
the viewer has, and without a locked advance width the text overruns the
viewBox on wider fonts.
-->
<g fill="none" stroke="#0092fd" stroke-linecap="round" stroke-width="7">
<path d="M12 16C24 16 25 32 30 32" opacity=".7"/>
<path d="M12 32H30"/>
<path d="M12 48C24 48 25 32 30 32" opacity=".7"/>
</g>
<circle cx="46" cy="32" r="8.5" fill="#0092fd"/>
<text x="72" y="41" fill="#0092fd" textLength="203" lengthAdjust="spacingAndGlyphs"
font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
font-size="26" letter-spacing="-0.8">shared<tspan opacity=".45">-</tspan>memory</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+159
View File
@@ -0,0 +1,159 @@
# Logo concepts
**Status:** exploration, nothing adopted
**Drafted:** 2026-08-12
Ten directions for the `shared-memory` mark. Eight are fresh concepts, two are
refinements of the mark currently shipping in `apps/web/public/logo.svg` and
`apps/web/app/icon.svg`.
Every concept is drafted as a real SVG in `docs/logo-concepts/`, authored the
same way the shipped mark is: a 64×64 viewBox, `currentColor` so it inherits the
surrounding text colour, round caps, and opacity rather than a second hue for
depth. That means any of them can be dropped straight into the app, and the tile
(favicon) variant is a mechanical derivation — wrap it in the rounded `#11151b`
rect and substitute the accent blues.
## How these were judged
A mark for this project has to survive three places: the browser tab at 16px,
the app header at 32–64px, and a README at whatever size GitHub renders. Small
size is the brutal filter — it is where most of the concepts below die, and it
is where the *currently shipped* mark is weakest, which is the single most
useful finding here.
Each was rendered at 120px, 24px and 16px and looked at, not just reasoned
about. The failures noted below are observed, not predicted.
---
## The concepts
### 01 · Venn core
Two outlined circles; the lens where they overlap is filled solid. The overlap
*is* the shared memory.
The most literal, most immediately-readable statement of "shared" in the set —
nobody needs it explained. That is also the problem: Venn diagrams are visual
public domain, and this would not be ownable. At 16px the two outlines fuse into
a single blob with a bright centre, which is legible but says nothing.
### 02 · Handoff
Two brackets facing each other around a single node — one session passing a
memory to the next.
Calm and symmetric, and the metaphor is exactly right for what the MCP does
across sessions. It reads as `( • )`, which unfortunately is also how a hundred
focus / aperture / eye icons read. Holds together at 16px better than 01.
### 03 · Braid
Two threads woven through each other with real over/under crossings, neither
subordinate to the other.
The most distinctive mark here by a wide margin, and the only one that looks
*crafted* rather than assembled from primitives. It also has the cleanest
meaning: two agents, one durable strand, and the weave is what makes it hold.
**It fails as a favicon.** At 16px the crossings collapse and it becomes an
unreadable smudge. This is not fixable by thickening strokes — the weave needs
the crossings to be visible, and they need room. Use it as a hero graphic or a
wordmark companion, not as the app icon.
### 04 · Return arc
An open ring with a solid node inside it: write it down, leave, come back to it.
The gap in the ring is where the next session enters.
The best small-size performer of the fresh concepts — completely legible at
16px, and the interior node keeps it from being just a shape. The risk is
literal: at small sizes it reads as a **©**. Worth checking against that
association before committing.
### 05 · Anchor
One fixed point that three separate agents reference from wherever they are.
Clean, balanced, and conceptually accurate — the memory is the fixed thing and
the clients are transient. But a three-spoke hub at this weight lands very close
to the Mercedes-Benz mark, and adjacent to the peace symbol. Both would come up
in any trademark review. Included for completeness; hard to recommend.
### 06 · Spine
A vertical spine with entries branching off it, the active one reaching furthest
to a node. An index, a ledger.
The only rectilinear mark in the set, so it stands apart from the others
immediately. It is also the most honest about what the product actually is —
a queryable index, not an abstraction. The risk is that it reads as a bar chart,
which pushes the association toward analytics rather than memory.
### 07 · Knot
A single continuous thread with no start and no end.
Renders flawlessly at every size tested — genuinely the most legible mark here.
That is its only advantage. It is the infinity symbol, which means near-zero
ownability, and it says "endless", not "shared". Kept in the set as the
legibility baseline the others are measured against.
### 08 · Triad
Three nodes joined by edges, one lit brighter than the others: the smallest
possible shared graph, with one participant currently active.
The strongest *conceptual* fit after the braid — it shows plurality, connection,
and activity in one figure, and the lit node gives it somewhere to go for a
loading or live state. Degrades gracefully: at 16px the edges thin out but three
dots in a triangle still reads. The concern is category crowding — triangular
node graphs are the default visual language of network and blockchain branding.
### 09 · Current mark, rebalanced
The shipped mark with three targeted changes, all aimed at 16px:
| | Shipped | Rebalanced |
|---|---|---|
| Converging strokes end at | `x=35` (2.5px from the node) | `x=30` (4px clear) |
| Outer stroke opacity | `.45` / `.55` | `.7` |
| Node radius | `7.5` | `8.5` |
At 16px the shipped mark loses its outer strokes to rasterisation and the
remaining gap between stroke and node fills in, so it renders as an
indeterminate horizontal smear. The rebalanced version resolves as an arrow
meeting a node at the same size.
This is the lowest-risk option on the table: it is not a rebrand, it keeps
whatever recognition the current mark has already earned, and it fixes a real
defect. It is worth doing *regardless* of what happens with the eight concepts
above.
### 10 · Lockup
Not an alternative mark — the missing asset. The rebalanced mark set against the
wordmark in the project's monospace stack, on a shared baseline, with the hyphen
dropped to 45% so `shared` and `memory` read as two things joined.
For README headers, the docs site, and the OAuth consent screen, where a bare
64px glyph is too little and the full app header is too much.
---
## Recommendation
Three separate decisions, not one:
1. **Ship 09 now.** The small-size failure of the current mark is a real defect
and the fix is a five-line diff. It does not depend on choosing a new
direction.
2. **Adopt 10** for the README and docs headers, using whichever mark wins.
3. **If a genuine replacement is wanted**, the shortlist is **08 Triad** (best
concept-to-legibility ratio) and **04 Return arc** (best legibility, pending
the © check). **03 Braid** is the most beautiful and should be used
*somewhere* — as a hero graphic — but cannot be the icon.
Concepts 01, 05 and 07 are documented here so the reasoning against them is on
record, not because they are live candidates.
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Venn core">
<title>Venn core</title>
<!-- Two participants; the filled lens is the memory they hold in common. -->
<g fill="none" stroke="currentColor" stroke-width="5" opacity=".5">
<circle cx="24" cy="32" r="15"/>
<circle cx="40" cy="32" r="15"/>
</g>
<path d="M32 19.31A15 15 0 0 1 32 44.69 15 15 0 0 1 32 19.31Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 470 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Handoff">
<title>Handoff</title>
<!-- Two sessions facing each other, passing one memory between them. -->
<g fill="none" stroke="currentColor" stroke-width="6.5" stroke-linecap="round" opacity=".65">
<path d="M26 16Q10 32 26 48"/>
<path d="M38 16Q54 32 38 48"/>
</g>
<circle cx="32" cy="32" r="7" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 451 B

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Braid">
<title>Braid</title>
<!--
Two independent threads woven into one strand - neither is subordinate,
and the weave is what holds. Crossings computed at (20,32) and (44,32);
the mask punches the first one so strand A reads as passing over it.
-->
<mask id="sm-braid-gap">
<rect width="64" height="64" fill="#fff"/>
<circle cx="20" cy="32" r="6.25" fill="#000"/>
</mask>
<g fill="none" stroke="currentColor" stroke-width="6.5" stroke-linecap="round">
<path d="M8 20C20 20 20 44 32 44 44 44 44 20 56 20" opacity=".75"/>
<path d="M8 44C20 44 20 20 32 20 44 20 44 44 56 44" mask="url(#sm-braid-gap)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 762 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Return arc">
<title>Return arc</title>
<!-- Write, leave, come back to it. The gap is where the next session enters. -->
<path d="M42.3 46.7A18 18 0 1 1 42.3 17.3" fill="none" stroke="currentColor"
stroke-width="6.5" stroke-linecap="round" opacity=".7"/>
<circle cx="30" cy="32" r="7.5" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 438 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Anchor">
<title>Anchor</title>
<!-- One fixed point that separate agents reference from wherever they are. -->
<g fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round" opacity=".6">
<path d="M32 8V20"/>
<path d="M11.2 44L21.6 38"/>
<path d="M52.8 44L42.4 38"/>
</g>
<circle cx="32" cy="32" r="8.5" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 475 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Spine">
<title>Spine</title>
<!-- An index: many entries on one shared spine, the active one reaching furthest. -->
<g fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round">
<path d="M16 13V51"/>
<path d="M16 21H34" opacity=".5"/>
<path d="M16 32H42"/>
<path d="M16 43H34" opacity=".5"/>
</g>
<circle cx="52" cy="32" r="5" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 504 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Knot">
<title>Knot</title>
<!-- One continuous thread with no start and no end; it persists past any session. -->
<path d="M32 32C38 20 50 20 50 32 50 44 38 44 32 32 26 20 14 20 14 32 14 44 26 44 32 32Z"
fill="none" stroke="currentColor" stroke-width="6.5" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 410 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Triad">
<title>Triad</title>
<!-- The smallest possible shared graph: three peers, one currently speaking. -->
<path d="M32 16L17 44H47Z" fill="none" stroke="currentColor" stroke-width="5"
stroke-linejoin="round" opacity=".4"/>
<g fill="currentColor">
<circle cx="32" cy="16" r="7"/>
<circle cx="17" cy="44" r="7" opacity=".55"/>
<circle cx="47" cy="44" r="7" opacity=".55"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 524 B

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Current mark, rebalanced">
<title>Current mark, rebalanced</title>
<!--
The shipped mark with three changes aimed at 16px: strokes stop earlier so
the gap to the node survives rasterisation, outer opacity raised .45 -> .7
so the neighbours don't drop out, node enlarged 7.5 -> 8.5.
-->
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="7">
<path d="M12 16C24 16 25 32 30 32" opacity=".7"/>
<path d="M12 32H30"/>
<path d="M12 48C24 48 25 32 30 32" opacity=".7"/>
</g>
<circle cx="46" cy="32" r="8.5" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 694 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 292 64" width="292" height="64" role="img" aria-label="shared-memory">
<title>shared-memory lockup</title>
<!-- Horizontal lockup: rebalanced mark + monospace wordmark on a shared baseline. -->
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="7">
<path d="M12 16C24 16 25 32 30 32" opacity=".7"/>
<path d="M12 32H30"/>
<path d="M12 48C24 48 25 32 30 32" opacity=".7"/>
</g>
<circle cx="46" cy="32" r="8.5" fill="currentColor"/>
<text x="72" y="41" fill="currentColor"
font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
font-size="26" letter-spacing="-0.8">shared<tspan opacity=".45">-</tspan>memory</text>
</svg>

After

Width:  |  Height:  |  Size: 751 B

+6 -2
View File
@@ -231,13 +231,17 @@ https://memory.example.com/auth/cli-callback
The first covers the loopback listener from README → *B. OAuth flow*; the
second is the manual-paste fallback from *C*. "Mobile and desktop
applications" permits arbitrary `https://` URIs alongside the loopback one, so
both live on the same platform.
both live on the same platform. If the server will also be added as a claude.ai
custom connector, that flow is brokered by claude.ai and comes back to
`https://claude.ai/api/mcp/auth_callback` — register it here too, or the
connector stops at the IdP's redirect-URI mismatch error before any login
prompt. See README → *Which redirect URIs to register*.
**Note the missing port.** Entra ignores the port component when matching
`http://localhost` redirect URIs, so the single registration
`http://localhost/callback` matches `http://localhost:33418/callback`,
`http://localhost:9999/callback`, and any other port. This is Entra's
equivalent of the Authentik regex (`^http://(127\.0\.0\.1|localhost):\d+/.*$`)
equivalent of the Authentik regex (`^http://(127\.0\.0\.1|localhost):\d+(/.*)?$`)
the README mentions — users can pick any `--callback-port` without
re-registering.
+10
View File
@@ -72,6 +72,16 @@ The redirect URI you register on the Web UI client is
`https://${domain_name}/api/auth/callback/oidc`, so plan the domain name
*before* configuring the IdP.
The MCP client needs its own list, and most of it does not depend on the
domain: a loopback URI for the Claude Code CLI, plus
`https://claude.ai/api/mcp/auth_callback` if anyone will add the server as a
claude.ai custom connector. Only the manual-paste fallback,
`https://${domain_name}/auth/cli-callback`, follows the domain. See
[Which redirect URIs to register](../README.md#which-redirect-uris-to-register)
— a client can only register its own redirect URI against an IdP that offers
Dynamic Client Registration, which Authentik gates behind an enterprise
licence, so plan on adding all of these by hand.
---
## Quick start