diff --git a/web/components/apikeys/AgentAccessPanel.tsx b/web/components/apikeys/AgentAccessPanel.tsx
new file mode 100644
index 0000000..6f236e6
--- /dev/null
+++ b/web/components/apikeys/AgentAccessPanel.tsx
@@ -0,0 +1,68 @@
+import { useState } from "react";
+
+/*
+ * Everything needed to point an LLM client at this instance, on the page where
+ * the credential it needs is minted. The endpoint is licence-gated
+ * (RequireFeature(license.FeatureMCP)), so the panel only exists where the
+ * connection would actually work.
+ *
+ * Styled as a well rather than a card: this is machine output being handed to
+ * the operator, the same treatment the install one-liner gets on /servers/new.
+ */
+function CopyLine({ label, value }: { label: string; value: string }) {
+ const [copied, setCopied] = useState(false);
+
+ async function copy() {
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+
+ return (
+
+ An LLM client can call this instance over MCP with an API key. The key needs mcp:read,
+ plus mcp:write for tools that change anything, and its other scopes and tag restriction
+ still decide what those tools can reach.
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/components/apikeys/ApiKeysPanel.tsx b/web/components/apikeys/ApiKeysPanel.tsx
index 6e64647..e22360b 100644
--- a/web/components/apikeys/ApiKeysPanel.tsx
+++ b/web/components/apikeys/ApiKeysPanel.tsx
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type ApiToken, type Role } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
+import { useLicense } from "@/lib/useLicense";
import {
AsyncBoundary,
Button,
@@ -15,6 +16,7 @@ import {
} from "@/components/ui";
import { KeyLedger, LedgerSkeleton } from "./KeyLedger";
import { KeyPosture } from "./KeyPosture";
+import { AgentAccessPanel } from "./AgentAccessPanel";
import { CreateKeyDialog, EXPIRY_OPTIONS } from "./CreateKeyDialog";
const ROLES: Role[] = ["owner", "admin", "member"];
@@ -40,6 +42,11 @@ export function ApiKeysPanel() {
const queryClient = useQueryClient();
const { user, isAdmin } = useAuth();
const toast = useToast();
+ // Strict rather than useLicense's optimistic hasFeature: both consumers
+ // below hide rather than disable, and a panel that appears and then
+ // vanishes once the licence loads reads as a glitch.
+ const { license } = useLicense();
+ const hasMCP = Boolean(license?.features?.mcp);
const [showAll, setShowAll] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
@@ -65,7 +72,10 @@ export function ApiKeysPanel() {
const { data: scopesData } = useQuery({ queryKey: ["token-scopes"], queryFn: api.listTokenScopes, enabled: createOpen });
const availableScopes = scopesData?.scopes ?? [];
- const resources = Array.from(new Set(availableScopes.map((s) => s.split(":")[0])));
+ // The scope vocabulary is the server's, but mcp:* is unreachable without
+ // the licence feature, and offering a grant that cannot be used is a
+ // support ticket waiting to happen.
+ const resources = Array.from(new Set(availableScopes.map((s) => s.split(":")[0]))).filter((r) => r !== "mcp" || hasMCP);
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["api-tokens"] });
@@ -216,6 +226,8 @@ export function ApiKeysPanel() {
+ {hasMCP && }
+