diff --git a/package.json b/package.json index 83425753..b2bddd94 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "test": "node scripts/run-tests.mjs", "test:cjs": "jest", "test:esm": "node --experimental-vm-modules node_modules/jest/bin/jest.js -c jest.esm.config.mjs --passWithNoTests", - "test:bot:unit": "node scripts/run-tests.mjs src/__tests__/botAuth.test.ts src/__tests__/botMe.test.ts src/__tests__/createWallet.bot.test.ts src/__tests__/walletIds.bot.test.ts src/__tests__/pendingTransactions.bot.test.ts src/__tests__/freeUtxos.bot.test.ts src/__tests__/addTransaction.bot.test.ts src/__tests__/nativeScript.bot.test.ts src/__tests__/governanceActiveProposals.test.ts src/__tests__/botBallotsUpsert.test.ts src/__tests__/signTransaction.bot.test.ts src/__tests__/submitDatum.bot.test.ts src/__tests__/resolveUtxoRefsFromChain.test.ts src/__tests__/resolveDRepAnchorFromUrl.test.ts src/__tests__/normalizePoolId.test.ts src/__tests__/createPendingMultisigTransaction.test.ts src/__tests__/proxyUtxos.test.ts src/__tests__/proxyTxBuilders.test.ts src/__tests__/proxySetup.bot.test.ts src/__tests__/proxyCleanup.bot.test.ts src/__tests__/proxyAccess.test.ts src/__tests__/proxySetupFinalization.test.ts src/__tests__/proxyCleanupFinalization.test.ts src/__tests__/proxyCiPreflight.test.ts src/__tests__/proxyCiOrphanAdoption.test.ts src/__tests__/proxyCiChainRecovery.test.ts src/__tests__/proxyBotSelection.test.ts src/__tests__/proxyCleanupRuntime.test.ts src/__tests__/ciSigningSelection.test.ts src/__tests__/ciScenarioManifest.test.ts src/__tests__/mcpTools.test.ts src/__tests__/mcpRoute.test.ts src/__tests__/oauthTokens.test.ts src/__tests__/oauthFlow.test.ts src/__tests__/oauthRefreshStore.test.ts src/__tests__/jestMockHygiene.test.ts src/__tests__/rationaleAnchor.test.ts", + "test:bot:unit": "node scripts/run-tests.mjs src/__tests__/botAuth.test.ts src/__tests__/botMe.test.ts src/__tests__/createWallet.bot.test.ts src/__tests__/walletIds.bot.test.ts src/__tests__/pendingTransactions.bot.test.ts src/__tests__/freeUtxos.bot.test.ts src/__tests__/addTransaction.bot.test.ts src/__tests__/nativeScript.bot.test.ts src/__tests__/governanceActiveProposals.test.ts src/__tests__/botBallotsUpsert.test.ts src/__tests__/signTransaction.bot.test.ts src/__tests__/submitDatum.bot.test.ts src/__tests__/resolveUtxoRefsFromChain.test.ts src/__tests__/resolveDRepAnchorFromUrl.test.ts src/__tests__/normalizePoolId.test.ts src/__tests__/createPendingMultisigTransaction.test.ts src/__tests__/proxyUtxos.test.ts src/__tests__/proxyTxBuilders.test.ts src/__tests__/proxySetup.bot.test.ts src/__tests__/proxyCleanup.bot.test.ts src/__tests__/proxyAccess.test.ts src/__tests__/proxySetupFinalization.test.ts src/__tests__/proxyCleanupFinalization.test.ts src/__tests__/proxyCiPreflight.test.ts src/__tests__/proxyCiOrphanAdoption.test.ts src/__tests__/proxyCiChainRecovery.test.ts src/__tests__/proxyBotSelection.test.ts src/__tests__/proxyCleanupRuntime.test.ts src/__tests__/ciSigningSelection.test.ts src/__tests__/ciScenarioManifest.test.ts src/__tests__/mcpTools.test.ts src/__tests__/mcpRoute.test.ts src/__tests__/oauthTokens.test.ts src/__tests__/oauthFlow.test.ts src/__tests__/oauthRefreshStore.test.ts src/__tests__/jestMockHygiene.test.ts src/__tests__/rationaleAnchor.test.ts src/__tests__/mcpConnections.test.ts", "test:bot:integration": "jest src/__tests__/botApi.integration.test.ts --runInBand", "test:bot": "npm run test:bot:unit && npm run test:bot:integration", "test:watch": "jest --watch", diff --git a/src/__tests__/mcpConnections.test.ts b/src/__tests__/mcpConnections.test.ts index 225afc2d..bc187389 100644 --- a/src/__tests__/mcpConnections.test.ts +++ b/src/__tests__/mcpConnections.test.ts @@ -12,6 +12,9 @@ type AnyAsyncMock = jest.Mock<(...args: any[]) => any>; const grantFindMany = jest.fn() as AnyAsyncMock; const grantFindUnique = jest.fn() as AnyAsyncMock; const grantDelete = jest.fn() as AnyAsyncMock; +const grantUpdate = jest.fn() as AnyAsyncMock; +const auditFindMany = jest.fn() as AnyAsyncMock; +const walletFindUnique = jest.fn() as AnyAsyncMock; const clientFindMany = jest.fn() as AnyAsyncMock; const tokenFindMany = jest.fn() as AnyAsyncMock; const tokenUpdateMany = jest.fn() as AnyAsyncMock; @@ -41,7 +44,10 @@ function ctx(session: { primaryWallet?: string | null; sessionWallets?: string[] findMany: grantFindMany, findUnique: grantFindUnique, delete: grantDelete, + update: grantUpdate, }, + auditLog: { findMany: auditFindMany }, + wallet: { findUnique: walletFindUnique }, oAuthClient: { findMany: clientFindMany }, oAuthRefreshToken: { findMany: tokenFindMany, updateMany: tokenUpdateMany }, $transaction: transaction, @@ -54,6 +60,11 @@ let caller: (c: unknown) => any; beforeEach(async () => { jest.clearAllMocks(); transaction.mockImplementation(async (ops: unknown[]) => [undefined, { count: 2 }]); + grantUpdate.mockResolvedValue({}); + auditFindMany.mockResolvedValue([]); + walletFindUnique.mockResolvedValue({ + id: "w1", signersAddresses: [ADDR], ownerAddress: ADDR, + }); grantDelete.mockResolvedValue({}); tokenUpdateMany.mockResolvedValue({ count: 2 }); const { mcpRouter } = await import("@/server/api/routers/mcp"); @@ -189,3 +200,127 @@ describe("revokeConnection", () => { ).rejects.toMatchObject({ code: "NOT_FOUND" }); }); }); + +describe("updateConnectionScopes", () => { + it("narrows a grant and keeps refresh tokens in step", async () => { + grantFindUnique.mockResolvedValue({ + id: "g1", scopes: ["wallets:read", "governance:read", "ballots:write"], + }); + + const out = await caller(ctx({ primaryWallet: ADDR })).updateConnectionScopes({ + clientId: "c1", + requesterAddress: ADDR, + scopes: ["wallets:read"], + }); + + expect(out).toEqual({ ok: true, scopes: ["wallets:read"] }); + // Grant and refresh tokens move together, or a refresh would re-widen it. + expect(transaction).toHaveBeenCalled(); + expect(tokenUpdateMany).toHaveBeenCalledWith( + expect.objectContaining({ data: { scopes: ["wallets:read"] } }), + ); + }); + + it("normalises to catalogue order regardless of input order", async () => { + grantFindUnique.mockResolvedValue({ id: "g1", scopes: [] }); + const out = await caller(ctx({ primaryWallet: ADDR })).updateConnectionScopes({ + clientId: "c1", + requesterAddress: ADDR, + scopes: ["ballots:write", "wallets:read"], + }); + expect(out.scopes).toEqual(["wallets:read", "ballots:write"]); + }); + + it("refuses to empty a grant — revoking is the honest action", async () => { + grantFindUnique.mockResolvedValue({ id: "g1", scopes: ["wallets:read"] }); + await expect( + caller(ctx({ primaryWallet: ADDR })).updateConnectionScopes({ + clientId: "c1", requesterAddress: ADDR, scopes: [], + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(transaction).not.toHaveBeenCalled(); + }); + + it("refuses an address the session does not hold", async () => { + await expect( + caller(ctx({ primaryWallet: ADDR })).updateConnectionScopes({ + clientId: "c1", requesterAddress: OTHER, scopes: ["wallets:read"], + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("404s on a grant that does not exist", async () => { + grantFindUnique.mockResolvedValue(null); + await expect( + caller(ctx({ primaryWallet: ADDR })).updateConnectionScopes({ + clientId: "nope", requesterAddress: ADDR, scopes: ["wallets:read"], + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); +}); + +describe("wallet activity", () => { + const row = (over: Record = {}) => ({ + id: "a1", + actorAddress: ADDR, + outcome: "success", + reason: null, + createdAt: new Date("2026-08-13T10:00:00Z"), + metadata: { tool: "multisig_list_wallets", client: "https://claude.ai/x", scope: "wallets:read", readOnly: true, status: 200, durationMs: 12 }, + ...over, + }); + + it("groups calls by client with counts and failures", async () => { + auditFindMany.mockResolvedValue([ + row(), + row({ id: "a2", metadata: { ...row().metadata, tool: "multisig_list_free_utxos" } }), + row({ id: "a3", outcome: "denied" }), + ]); + + const [client] = await caller(ctx({ primaryWallet: ADDR })).walletClients({ + walletId: "w1", + }); + + expect(client).toMatchObject({ + client: "https://claude.ai/x", + calls: 3, + failures: 1, + tools: ["multisig_list_free_utxos", "multisig_list_wallets"], + }); + }); + + it("only reads MCP tool rows for this wallet", async () => { + await caller(ctx({ primaryWallet: ADDR })).walletClients({ walletId: "w1" }); + expect(auditFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + action: "mcp.tool.called", + resourceType: "wallet", + resourceId: "w1", + }, + }), + ); + }); + + it("filters the drill-down to one client", async () => { + auditFindMany.mockResolvedValue([ + row(), + row({ id: "a2", metadata: { ...row().metadata, client: "other-client" } }), + ]); + const rows = await caller(ctx({ primaryWallet: ADDR })).walletToolUsage({ + walletId: "w1", + client: "other-client", + }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ client: "other-client" }); + }); + + it("refuses a wallet the caller is not a signer of", async () => { + walletFindUnique.mockResolvedValue({ + id: "w1", signersAddresses: [OTHER], ownerAddress: OTHER, + }); + await expect( + caller(ctx({ primaryWallet: ADDR })).walletClients({ walletId: "w1" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); diff --git a/src/__tests__/mcpRoute.test.ts b/src/__tests__/mcpRoute.test.ts index 4e97e5a5..64a0ea71 100644 --- a/src/__tests__/mcpRoute.test.ts +++ b/src/__tests__/mcpRoute.test.ts @@ -17,6 +17,8 @@ const enforceBodySizeMock = jest.fn<() => boolean>(); const verifyJwtMock: jest.Mock = jest.fn(); const isBotJwtMock: jest.Mock = jest.fn(); const findBotUserMock: jest.Mock = jest.fn(); +const grantFindUniqueMock: jest.Mock = jest.fn(); +const auditCreateMock: jest.Mock = jest.fn(); jest.mock("@/lib/cors", () => ({ __esModule: true, @@ -40,7 +42,11 @@ jest.mock("@/lib/verifyJwt", () => ({ jest.mock("@/server/db", () => ({ __esModule: true, - db: { botUser: { findUnique: findBotUserMock } }, + db: { + botUser: { findUnique: findBotUserMock }, + oAuthGrant: { findUnique: grantFindUniqueMock }, + auditLog: { create: auditCreateMock }, + }, })); const HUMAN_ADDRESS = "addr_test1qphuman000000000000000000000000000000"; @@ -178,6 +184,7 @@ beforeEach(() => { enforceBodySizeMock.mockReturnValue(true); verifyJwtMock.mockReturnValue({ address: HUMAN_ADDRESS }); isBotJwtMock.mockReturnValue(false); + (auditCreateMock as any).mockResolvedValue({}); }); describe("POST /api/mcp — transport", () => { @@ -402,3 +409,72 @@ describe("POST /api/mcp — tools/call", () => { expect(typeof payload.result?.isError).toBe("boolean"); }); }); + +describe("POST /api/mcp — the stored grant is authoritative", () => { + // Access tokens are self-contained and live an hour. If the token's `scope` + // claim were trusted on its own, revoking a connection or removing a + // permission in the profile would not take effect until it expired. + const OAUTH_SUBJECT = "addr_test1qpoauth"; + + function oauthToken(scopes: string[]) { + const jwt = require("jsonwebtoken") as typeof import("jsonwebtoken"); + return jwt.sign( + { + sub: OAUTH_SUBJECT, + aud: "http://localhost:3000/api/mcp", + typ: "mcp_at", + cid: "https://claude.ai/x", + scope: scopes.join(" "), + addrs: [OAUTH_SUBJECT], + jti: "t1", + }, + process.env.JWT_SECRET as string, + { issuer: "http://localhost:3000", expiresIn: "1h" }, + ); + } + + const listWith = (token: string) => { + const { headers, body } = modern("tools/list"); + const res = createResponse(); + return handler( + createRequest(body, { ...headers, authorization: `Bearer ${token}` }), + res, + ).then(() => res); + }; + + it("401s when the grant has been revoked", async () => { + (grantFindUniqueMock as any).mockResolvedValue(null); + const res = await listWith(oauthToken(["wallets:read"])); + expect(res._status).toBe(401); + }); + + it("drops a permission removed from the grant, even though the token still claims it", async () => { + (grantFindUniqueMock as any).mockResolvedValue({ + scopes: ["wallets:read"], + grantedAddresses: [OAUTH_SUBJECT], + }); + + const res = await listWith(oauthToken(["wallets:read", "ballots:write"])); + + const payload = res.body() as { result?: { tools?: { name: string }[] } }; + const names = payload.result?.tools?.map((t) => t.name) ?? []; + expect(names).toContain("multisig_list_wallets"); + expect(names).not.toContain("ballot_upsert"); + }); + + it("never widens a token beyond what it was issued with", async () => { + // Grant widened after the token was minted: the token must not gain reach. + (grantFindUniqueMock as any).mockResolvedValue({ + scopes: ["wallets:read", "governance:read", "ballots:write"], + grantedAddresses: [OAUTH_SUBJECT], + }); + + const res = await listWith(oauthToken(["wallets:read"])); + + const payload = res.body() as { result?: { tools?: { name: string }[] } }; + const names = payload.result?.tools?.map((t) => t.name) ?? []; + expect(names).toContain("multisig_list_wallets"); + expect(names).not.toContain("ballot_upsert"); + expect(names).not.toContain("governance_open_proposals"); + }); +}); diff --git a/src/__tests__/mcpTools.test.ts b/src/__tests__/mcpTools.test.ts index d201278e..a7fe7eee 100644 --- a/src/__tests__/mcpTools.test.ts +++ b/src/__tests__/mcpTools.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "@jest/globals"; -import { existsSync } from "fs"; +import { existsSync, readFileSync } from "fs"; import { join } from "path"; import { MCP_TOOLS, toolsForScopes } from "@/lib/mcp/tools"; import { MCP_SCOPES, isMcpScope, parseMcpScopes } from "@/lib/mcp/scopes"; import { mcpScopesForBot } from "@/lib/mcp/auth"; +import { MCP_TOOL_SUMMARIES } from "@/data/mcp-tools"; +import { MCP_TOOL_ACTION } from "@/lib/mcp/server"; import type { BotScope } from "@/lib/auth/botKey"; const V1_DIR = join(process.cwd(), "src", "pages", "api", "v1"); @@ -180,3 +182,41 @@ describe("bot scope projection", () => { ]); }); }); + +describe("published tool list (src/data/mcp-tools.ts)", () => { + // The landing page cannot import the real registry — it pulls the API + // handlers and the Mesh WASM with them — so the displayed list is a separate + // data file. This keeps the two honest. + it("lists exactly the registered tools, in the same order", () => { + expect(MCP_TOOL_SUMMARIES.map((t) => t.name)).toEqual( + MCP_TOOLS.map((t) => t.name), + ); + }); + + it("states the same scope the registry enforces", () => { + const actual = new Map(MCP_TOOLS.map((t) => [t.name, t.scope])); + for (const summary of MCP_TOOL_SUMMARIES) { + expect(summary.scope).toBe(actual.get(summary.name)); + } + }); + + it("gives every tool a blurb", () => { + for (const summary of MCP_TOOL_SUMMARIES) { + expect(summary.blurb.length).toBeGreaterThan(15); + } + }); +}); + +describe("audit action constant", () => { + // src/server/api/routers/mcp.ts hard-codes this string rather than importing + // it, because importing src/lib/mcp/server.ts would drag the MCP SDK and the + // whole tool registry into the tRPC bundle. If they drift, the wallet + // activity view silently returns nothing. + it("matches the literal the tRPC router queries on", () => { + const router = readFileSync( + join(process.cwd(), "src", "server", "api", "routers", "mcp.ts"), + "utf8", + ); + expect(router).toContain(`const MCP_TOOL_ACTION = "${MCP_TOOL_ACTION}"`); + }); +}); diff --git a/src/components/pages/homepage/index.tsx b/src/components/pages/homepage/index.tsx index 404aec39..0247d466 100644 --- a/src/components/pages/homepage/index.tsx +++ b/src/components/pages/homepage/index.tsx @@ -28,6 +28,7 @@ import { DRepPreview, StakingPreview, } from "@/components/pages/homepage/previews"; +import { MCP_TOOL_SUMMARIES } from "@/data/mcp-tools"; // Prompts cycled by the hero typewriter. Deliberately phrased as things you // ask an assistant, not as commands for one product — the endpoint is plain MCP @@ -751,14 +752,40 @@ export function PageHomepage() { +
+

What it can do

+

+ Thirteen tools, grouped by the permission they need. You choose + which permissions to grant, and can change them later. +

+
+ {(["wallets:read", "governance:read", "ballots:write"] as const).map( + (scope) => ( +
+ {scope} +
    + {MCP_TOOL_SUMMARIES.filter((t) => t.scope === scope).map((t) => ( +
  • + {t.name}{" "} + — {t.blurb} +
  • + ))} +
+
+ ), + )} +
+
+

Read-only, by design.{" "} - A connected client can list wallets, pending transactions, - spendable UTxOs, proxies and active proposals, and draft - governance ballot rationales. It cannot sign - transactions, move funds, or submit a vote on-chain. Manage or - revoke connections any time under{" "} + A connected client can read your wallets and governance, draft + ballots, and publish a rationale to IPFS. It{" "} + cannot sign transactions, move funds, or + submit a vote on-chain — those stay with you and your + co-signers. Manage permissions or revoke a connection any time + under{" "} your profile diff --git a/src/components/pages/user/McpConnectionsCard.tsx b/src/components/pages/user/McpConnectionsCard.tsx index 85ef28b5..d6df102f 100644 --- a/src/components/pages/user/McpConnectionsCard.tsx +++ b/src/components/pages/user/McpConnectionsCard.tsx @@ -13,7 +13,8 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useToast } from "@/hooks/use-toast"; -import { MCP_SCOPE_DESCRIPTIONS, type McpScope } from "@/lib/mcp/scopes"; +import { Checkbox } from "@/components/ui/checkbox"; +import { MCP_SCOPES, MCP_SCOPE_DESCRIPTIONS, type McpScope } from "@/lib/mcp/scopes"; import { useUserStore } from "@/lib/zustand/user"; import { api } from "@/utils/api"; @@ -32,6 +33,8 @@ export default function McpConnectionsCard() { clientId: string; clientName: string; } | null>(null); + /** Per-connection scope edits, keyed by clientId, until saved. */ + const [drafts, setDrafts] = useState>({}); const utils = api.useUtils(); const { data: connections, isLoading } = api.mcp.listConnections.useQuery( @@ -39,6 +42,23 @@ export default function McpConnectionsCard() { { enabled: Boolean(userAddress) }, ); + const updateScopes = api.mcp.updateConnectionScopes.useMutation({ + onSuccess: (_r, vars) => { + void utils.mcp.listConnections.invalidate(); + setDrafts((d) => { + const next = { ...d }; + delete next[vars.clientId]; + return next; + }); + toast({ + title: "Permissions updated", + description: "Applies to the client's next request.", + }); + }, + onError: (error) => + toast({ title: "Could not update", description: error.message, variant: "destructive" }), + }); + const revoke = api.mcp.revokeConnection.useMutation({ onSuccess: (result) => { void utils.mcp.listConnections.invalidate(); @@ -112,14 +132,68 @@ export default function McpConnectionsCard() { {c.clientId} -

+
+ Permissions + {MCP_SCOPES.map((scope) => { + const current = drafts[c.clientId] ?? (c.scopes as McpScope[]); + const checked = current.includes(scope); + return ( + + ); + })} + {drafts[c.clientId] && ( +
+ + +
+ )} +

Approved {new Date(c.approvedAt).toLocaleDateString()} diff --git a/src/components/pages/wallet/info/index.tsx b/src/components/pages/wallet/info/index.tsx index 80af3a73..1a9f78ac 100644 --- a/src/components/pages/wallet/info/index.tsx +++ b/src/components/pages/wallet/info/index.tsx @@ -12,6 +12,7 @@ import ProxyControlCard from "./proxy-control"; import { UpgradeGovernanceWallet } from "./upgrade-governance-wallet"; import WalletDetailSkeleton from "@/components/pages/wallet/wallet-detail-skeleton"; import { WalletNotificationSettings } from "./wallet-notification-settings"; +import McpActivityCard from "./mcp-activity"; export default function WalletInfo() { const { appWallet } = useAppWallet(); @@ -28,6 +29,7 @@ export default function WalletInfo() { )} + diff --git a/src/components/pages/wallet/info/mcp-activity.tsx b/src/components/pages/wallet/info/mcp-activity.tsx new file mode 100644 index 00000000..3521e47d --- /dev/null +++ b/src/components/pages/wallet/info/mcp-activity.tsx @@ -0,0 +1,128 @@ +import { useState } from "react"; +import { ChevronDown, ChevronRight, Loader2, Plug } from "lucide-react"; + +import CardUI from "@/components/ui/card-content"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { api } from "@/utils/api"; +import type { Wallet } from "@/types/wallet"; + +/** + * AI clients that have used this wallet, and what they actually did. + * + * The profile card answers "what have I connected"; this answers "what has + * touched *this* wallet", which is the question a co-signer asks. It reads the + * audit trail rather than the grants — a grant says what a client *may* do, + * these rows say what it did. + */ +export default function McpActivityCard({ appWallet }: { appWallet: Wallet }) { + const [expanded, setExpanded] = useState(null); + + const { data: clients, isLoading } = api.mcp.walletClients.useQuery( + { walletId: appWallet.id }, + { enabled: Boolean(appWallet.id) }, + ); + + const { data: usage, isLoading: usageLoading } = api.mcp.walletToolUsage.useQuery( + { walletId: appWallet.id, client: expanded ?? undefined, limit: 50 }, + { enabled: expanded !== null }, + ); + + return ( + + {isLoading ? ( +

+ Loading activity… +
+ ) : !clients || clients.length === 0 ? ( +

+ No AI client has used this wallet yet. Connections are approved per + user; anything that reads this wallet will be listed here. +

+ ) : ( +
+ {clients.map((c) => { + const open = expanded === c.client; + return ( +
+ + + {open && ( +
+ {usageLoading ? ( +
+ Loading calls… +
+ ) : !usage || usage.length === 0 ? ( +

+ No calls recorded. +

+ ) : ( +
    + {usage.map((row) => ( +
  • + + {row.tool} + {!row.readOnly && ( + write + )} + {row.outcome !== "success" && ( + {row.outcome} + )} + + + {new Date(row.at).toLocaleString()} + {row.durationMs !== null && ` · ${row.durationMs}ms`} + +
  • + ))} +
+ )} +
+ )} +
+ ); + })} + +

+ Manage or revoke these connections from{" "} + + your profile + + . +

+
+ )} + + ); +} diff --git a/src/data/mcp-tools.ts b/src/data/mcp-tools.ts new file mode 100644 index 00000000..880f4fed --- /dev/null +++ b/src/data/mcp-tools.ts @@ -0,0 +1,84 @@ +/** + * The MCP tool surface, for display. + * + * A plain data file with no imports on purpose: the real registry in + * `src/lib/mcp/tools.ts` pulls the API handlers (and, transitively, the Mesh + * WASM), so it must never reach a client bundle. `src/__tests__/mcpTools.test.ts` + * asserts this list matches the registry name-for-name and scope-for-scope, so + * it cannot drift. + */ + +export type McpToolSummary = { + name: string; + scope: "wallets:read" | "governance:read" | "ballots:write"; + /** One line, phrased for someone deciding whether to connect. */ + blurb: string; +}; + +export const MCP_TOOL_SUMMARIES: McpToolSummary[] = [ + { + name: "multisig_whoami", + scope: "wallets:read", + blurb: "Which account and permissions the connection is acting with.", + }, + { + name: "multisig_list_wallets", + scope: "wallets:read", + blurb: "Your multisig wallets and their ids.", + }, + { + name: "multisig_list_pending_transactions", + scope: "wallets:read", + blurb: "Transactions waiting for signatures, and how many they still need.", + }, + { + name: "multisig_list_free_utxos", + scope: "wallets:read", + blurb: "UTxOs not already locked by a pending transaction — what you can actually spend.", + }, + { + name: "multisig_list_proxies", + scope: "wallets:read", + blurb: "Active Plutus proxy scripts attached to a wallet.", + }, + { + name: "multisig_proxy_drep_info", + scope: "wallets:read", + blurb: "Whether a proxy's DRep credential is registered on-chain.", + }, + { + name: "multisig_lookup_wallet", + scope: "wallets:read", + blurb: "Find on-chain multisig registration metadata by participant key hash.", + }, + { + name: "governance_list_active_proposals", + scope: "governance:read", + blurb: "Governance proposals still open, with titles and abstracts.", + }, + { + name: "governance_list_ballots", + scope: "governance:read", + blurb: "Your team's internal decision log — how signers decided, and why.", + }, + { + name: "governance_vote_history", + scope: "governance:read", + blurb: "Votes your DRep has actually cast on-chain.", + }, + { + name: "governance_open_proposals", + scope: "governance:read", + blurb: "Active proposals you have not voted on yet — the outstanding decisions.", + }, + { + name: "ballot_upsert", + scope: "ballots:write", + blurb: "Create or update a ballot draft: a choice per proposal, plus rationale text.", + }, + { + name: "ballot_publish_rationale", + scope: "ballots:write", + blurb: "Publish a rationale to IPFS and record its anchor, ready for you to vote.", + }, +]; diff --git a/src/lib/mcp/auth.ts b/src/lib/mcp/auth.ts index 731a0501..edda41af 100644 --- a/src/lib/mcp/auth.ts +++ b/src/lib/mcp/auth.ts @@ -4,7 +4,7 @@ import type { NextApiRequest } from "next"; import { db } from "@/server/db"; import { parseScope, scopeIncludes, type BotScope } from "@/lib/auth/botKey"; import { isBotJwt, verifyJwt } from "@/lib/verifyJwt"; -import { MCP_SCOPES, type McpScope } from "@/lib/mcp/scopes"; +import { MCP_SCOPES, isMcpScope, type McpScope } from "@/lib/mcp/scopes"; import { issuerOrigin, resourceUrl } from "@/lib/oauth/config"; import { verifyAccessToken } from "@/lib/oauth/accessToken"; @@ -85,10 +85,35 @@ export async function resolveMcpCaller( resource: resourceUrl(req), }); if (oauth) { + // The stored grant is authoritative, not the token's `scope` claim. + // + // Access tokens are self-contained and live for an hour, so trusting the + // claim alone would mean a permission removed in the profile — or the whole + // connection revoked — kept working until the token happened to expire. + // Re-reading the grant costs one indexed lookup and makes the UI mean what + // it says: changes apply on the very next request. + const grant = await db.oAuthGrant.findUnique({ + where: { + subjectAddress_clientId: { + subjectAddress: oauth.subject, + clientId: oauth.clientId, + }, + }, + }); + if (!grant) return null; // revoked, or never granted + + // Intersect rather than replace: a token must never gain reach it was not + // issued with, even if the grant was later widened. + const granted = grant.scopes.filter(isMcpScope); + const scopes = oauth.scopes.filter((s) => granted.includes(s)); + return { subject: oauth.subject, - addresses: oauth.addresses, - scopes: oauth.scopes, + // The grant also decides which wallets are in play. + addresses: grant.grantedAddresses.length > 0 + ? oauth.addresses.filter((a) => grant.grantedAddresses.includes(a)) + : oauth.addresses, + scopes, clientName: oauth.clientId, botId: null, expiresAt: oauth.expiresAt, diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts index b786cae4..bed18f37 100644 --- a/src/lib/mcp/server.ts +++ b/src/lib/mcp/server.ts @@ -1,8 +1,10 @@ import { McpServer, fromJsonSchema } from "@modelcontextprotocol/server"; +import { db } from "@/server/db"; +import { audit } from "@/lib/observability/audit"; import type { McpCaller } from "@/lib/mcp/auth"; import type { V1Result } from "@/lib/mcp/invokeV1"; -import { toolsForScopes } from "@/lib/mcp/tools"; +import { toolsForScopes, type McpToolDef } from "@/lib/mcp/tools"; export const MCP_SERVER_NAME = "mesh-multisig"; export const MCP_SERVER_VERSION = "0.1.0"; @@ -34,10 +36,31 @@ export function createMcpServer(caller: McpCaller, clientIp: string): McpServer annotations: tool.annotations, }, async (args: unknown) => { - const result = await tool.run( - (args ?? {}) as Record, - { caller, clientIp }, - ); + const input = (args ?? {}) as Record; + const startedAt = Date.now(); + let result; + try { + result = await tool.run(input, { caller, clientIp }); + } catch (error) { + void recordToolCall({ + tool, + caller, + input, + clientIp, + status: 0, + durationMs: Date.now() - startedAt, + reason: error instanceof Error ? error.message : String(error), + }); + throw error; + } + void recordToolCall({ + tool, + caller, + input, + clientIp, + status: result.status, + durationMs: Date.now() - startedAt, + }); return toToolResult(result); }, ); @@ -46,6 +69,55 @@ export function createMcpServer(caller: McpCaller, clientIp: string): McpServer return server; } +/** Action name for every MCP tool invocation in the audit log. */ +export const MCP_TOOL_ACTION = "mcp.tool.called"; + +/** + * Record one tool call. + * + * Written against the wallet the call touched (`resourceType: "wallet"`), so + * the per-wallet activity view is an indexed lookup rather than a scan. Fire + * and forget: `audit` swallows its own failures, and an audit miss must never + * break a tool call. + * + * Only the walletId is taken from the arguments. Tool inputs can carry + * user-authored prose — ballot rationales, descriptions — which has no place in + * an audit row. + */ +function recordToolCall(args: { + tool: McpToolDef; + caller: McpCaller; + input: Record; + clientIp: string; + status: number; + durationMs: number; + reason?: string; +}) { + const walletId = + typeof args.input.walletId === "string" ? args.input.walletId : null; + + return audit(db, { + actorAddress: args.caller.subject, + actorType: args.caller.botId ? "bot" : "user", + action: MCP_TOOL_ACTION, + resourceType: walletId ? "wallet" : "mcp", + resourceId: walletId, + ip: args.clientIp, + outcome: + args.status === 0 ? "error" : args.status >= 400 ? "denied" : "success", + ...(args.reason ? { reason: args.reason.slice(0, 200) } : {}), + metadata: { + tool: args.tool.name, + scope: args.tool.scope, + // The OAuth client id, or null for a v1 bearer / bot caller. + client: args.caller.clientName, + readOnly: args.tool.annotations.readOnlyHint, + status: args.status, + durationMs: args.durationMs, + }, + }); +} + /** * Map a v1 handler result onto an MCP tool result. * diff --git a/src/server/api/routers/mcp.ts b/src/server/api/routers/mcp.ts index 05a7ee56..238ccb6a 100644 --- a/src/server/api/routers/mcp.ts +++ b/src/server/api/routers/mcp.ts @@ -3,6 +3,22 @@ import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/lib/observability/audit"; +import { assertWalletAccess } from "@/server/api/auth"; +import { MCP_SCOPES, isMcpScope } from "@/lib/mcp/scopes"; + +/** Mirrors MCP_TOOL_ACTION in src/lib/mcp/server.ts. Duplicated rather than + * imported: that module pulls the MCP SDK and the whole tool registry, which + * has no place in the tRPC bundle. Kept honest by a test. */ +const MCP_TOOL_ACTION = "mcp.tool.called"; + +type ToolCallMetadata = { + tool?: string; + client?: string | null; + scope?: string; + readOnly?: boolean; + status?: number; + durationMs?: number; +}; /** * MCP connections — the OAuth grants a user has approved for AI clients. @@ -95,6 +111,164 @@ export const mcpRouter = createTRPCRouter({ }); }), + /** + * Change what a connected client is allowed to do. + * + * The grant is what the MCP endpoint reads on every request (see + * `resolveMcpCaller`), so a change here takes effect on the client's next + * call — not whenever its hour-long access token happens to expire. + */ + updateConnectionScopes: protectedProcedure + .input( + z.object({ + clientId: z.string().min(1), + requesterAddress: z.string().min(1), + scopes: z.array(z.enum(MCP_SCOPES)), + }), + ) + .mutation(async ({ ctx, input }) => { + const subjectAddress = requireSessionAddress(ctx, input.requesterAddress); + + const grant = await ctx.db.oAuthGrant.findUnique({ + where: { + subjectAddress_clientId: { subjectAddress, clientId: input.clientId }, + }, + }); + if (!grant) { + throw new TRPCError({ code: "NOT_FOUND", message: "Connection not found" }); + } + + // Removing every permission leaves a connection that authenticates but can + // do nothing, which reads as broken. Revoking is the honest action. + if (input.scopes.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Leave at least one permission, or revoke the connection instead.", + }); + } + + const scopes = MCP_SCOPES.filter((s) => input.scopes.includes(s)); + const removed = grant.scopes.filter((s) => !scopes.includes(s as never)); + + await ctx.db.$transaction([ + ctx.db.oAuthGrant.update({ + where: { id: grant.id }, + data: { scopes }, + }), + // Keep refresh tokens in step, so a refresh cannot re-widen the grant. + ctx.db.oAuthRefreshToken.updateMany({ + where: { subjectAddress, clientId: input.clientId, revokedAt: null }, + data: { scopes }, + }), + ]); + + void audit(ctx.db, { + actorAddress: subjectAddress, + actorType: "user", + action: "mcp.connection.scopes_updated", + resourceType: "oauth_grant", + resourceId: input.clientId, + outcome: "success", + metadata: { scopes, removed }, + }); + + return { ok: true, scopes }; + }), + + /** + * Which AI clients have touched this wallet, and how much. + * + * Reads the audit trail rather than the grants: a grant says what a client + * *may* do, this says what it actually did to this wallet. + */ + walletClients: protectedProcedure + .input(z.object({ walletId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + await assertWalletAccess(ctx, input.walletId); + + const rows = await ctx.db.auditLog.findMany({ + where: { + action: MCP_TOOL_ACTION, + resourceType: "wallet", + resourceId: input.walletId, + }, + orderBy: { createdAt: "desc" }, + take: 500, + }); + + const byClient = new Map< + string, + { client: string; actorAddress: string | null; calls: number; failures: number; lastUsedAt: Date; tools: Set } + >(); + + for (const row of rows) { + const meta = (row.metadata ?? {}) as ToolCallMetadata; + const key = meta.client ?? row.actorAddress ?? "unknown"; + const existing = byClient.get(key); + if (existing) { + existing.calls += 1; + if (row.outcome !== "success") existing.failures += 1; + if (meta.tool) existing.tools.add(meta.tool); + } else { + byClient.set(key, { + client: key, + actorAddress: row.actorAddress, + calls: 1, + failures: row.outcome === "success" ? 0 : 1, + lastUsedAt: row.createdAt, + tools: new Set(meta.tool ? [meta.tool] : []), + }); + } + } + + return [...byClient.values()] + .map((c) => ({ ...c, tools: [...c.tools].sort() })) + .sort((a, b) => b.lastUsedAt.getTime() - a.lastUsedAt.getTime()); + }), + + /** The individual tool calls behind those totals. */ + walletToolUsage: protectedProcedure + .input( + z.object({ + walletId: z.string().min(1), + client: z.string().optional(), + limit: z.number().int().min(1).max(200).default(50), + }), + ) + .query(async ({ ctx, input }) => { + await assertWalletAccess(ctx, input.walletId); + + const rows = await ctx.db.auditLog.findMany({ + where: { + action: MCP_TOOL_ACTION, + resourceType: "wallet", + resourceId: input.walletId, + }, + orderBy: { createdAt: "desc" }, + take: 500, + }); + + return rows + .map((row) => { + const meta = (row.metadata ?? {}) as ToolCallMetadata; + return { + id: row.id, + at: row.createdAt, + tool: meta.tool ?? "unknown", + client: meta.client ?? null, + actorAddress: row.actorAddress, + scope: meta.scope ?? null, + readOnly: meta.readOnly ?? true, + outcome: row.outcome, + status: meta.status ?? null, + durationMs: meta.durationMs ?? null, + reason: row.reason, + }; + }) + .filter((r) => !input.client || r.client === input.client) + .slice(0, input.limit); + }), + /** * Revoke a connection: drop the consent record and kill every refresh token * issued under it.