feat: surface the mcp endpoint and its scopes on the api keys page

This commit is contained in:
2026-09-08 14:14:44 +00:00
parent 14b947f791
commit e5b9894384
2 changed files with 81 additions and 1 deletions
@@ -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 (
<div className="flex flex-col overflow-hidden rounded border border-border bg-well sm:flex-row">
<pre className="flex-1 overflow-x-auto p-3 font-mono text-xs text-text-primary">{value}</pre>
<button
type="button"
onClick={copy}
className="border-t border-border bg-surface-2 px-4 py-2.5 text-sm text-text-primary hover:bg-border sm:border-l sm:border-t-0"
>
{copied ? "Copied" : "Copy"}
<span className="sr-only"> the {label}</span>
</button>
</div>
);
}
export function AgentAccessPanel() {
const origin = typeof window === "undefined" ? "https://YOUR-INSTANCE" : window.location.origin;
const endpoint = `${origin}/api/mcp`;
const config = JSON.stringify(
{
mcpServers: {
vantage: {
type: "http",
url: endpoint,
headers: { Authorization: "Bearer vt_your_key_here" },
},
},
},
null,
2,
);
return (
<section className="mt-8 rounded-lg border border-border bg-surface p-4 sm:p-5">
<h2 className="text-base font-semibold text-text-primary">Agent access</h2>
<p className="mt-1 max-w-[65ch] text-sm text-text-secondary">
An LLM client can call this instance over MCP with an API key. The key needs <code className="font-mono text-xs">mcp:read</code>,
plus <code className="font-mono text-xs">mcp:write</code> for tools that change anything, and its other scopes and tag restriction
still decide what those tools can reach.
</p>
<div className="mt-4 flex flex-col gap-3">
<CopyLine label="endpoint" value={endpoint} />
<CopyLine label="client configuration" value={config} />
</div>
</section>
);
}
+13 -1
View File
@@ -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() {
</AsyncBoundary>
</Card>
{hasMCP && <AgentAccessPanel />}
<ConfirmDialog
open={revoking !== null}
title="Revoke key"