Skip to content

Commit bcc8fe3

Browse files
authored
Merge pull request #35 from chainapsis/fix/keplr-api-key-auto-resolve
fix: auto-resolve API key for keplr_api_* tools and prevent URL hallucination
2 parents abd4a6f + f4487e7 commit bcc8fe3

4 files changed

Lines changed: 119 additions & 13 deletions

File tree

packages/server/src/__tests__/tools/keplr-rpc.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ vi.mock("node:os", async (importOriginal) => {
2727
return { ...actual, homedir: vi.fn(actual.homedir) };
2828
});
2929

30+
const mockGetRpcResolver = vi.fn();
31+
vi.mock("../../rpc/resolver.js", () => ({
32+
getRpcResolver: (...args: unknown[]) => mockGetRpcResolver(...args),
33+
}));
34+
3035
// Dynamic import to avoid hoisting issues
3136
let keplrRpcPlugin: typeof import("../../plugins/keplr-rpc.js").default;
3237

@@ -60,6 +65,8 @@ beforeEach(async () => {
6065
storePendingAction: vi.fn().mockReturnValue("mock-confirmation-token"),
6166
} as never);
6267
mockFetch.mockReset();
68+
// Default: no API key configured in resolver
69+
mockGetRpcResolver.mockReturnValue({ apiKey: undefined, hasApiKey: false });
6370
await keplrRpcPlugin.register(server as never, store as never);
6471
});
6572

@@ -644,3 +651,55 @@ describe("HTTP error mapping", () => {
644651
);
645652
});
646653
});
654+
655+
// ─── API key auto-resolution ───────────────────────────────────────
656+
describe("API key auto-resolution", () => {
657+
it("should auto-detect API key from resolver when not provided", async () => {
658+
mockGetRpcResolver.mockReturnValue({
659+
apiKey: "keplr_from_env",
660+
hasApiKey: true,
661+
});
662+
mockFetch.mockResolvedValueOnce(
663+
okJson({ balance: 1000000, usage: { last7Days: {}, byChain: [] } }),
664+
);
665+
666+
const tool = server.getTool("keplr_api_get_usage_summary")!;
667+
const result = await tool.handler({});
668+
const parsed = parseToolResponse(result);
669+
expect(parsed).toHaveProperty("balance", 1000000);
670+
671+
// Verify the resolved key was used in the fetch URL
672+
const fetchUrl = mockFetch.mock.calls[0][0] as string;
673+
expect(fetchUrl).toContain("keplr_from_env");
674+
});
675+
676+
it("should prefer explicit API key over configured key", async () => {
677+
mockGetRpcResolver.mockReturnValue({
678+
apiKey: "keplr_from_env",
679+
hasApiKey: true,
680+
});
681+
mockFetch.mockResolvedValueOnce(
682+
okJson({ balance: 500000, usage: { last7Days: {}, byChain: [] } }),
683+
);
684+
685+
const tool = server.getTool("keplr_api_get_usage_summary")!;
686+
const result = await tool.handler({ apiKey: "keplr_explicit" });
687+
const parsed = parseToolResponse(result);
688+
expect(parsed).toHaveProperty("balance", 500000);
689+
690+
const fetchUrl = mockFetch.mock.calls[0][0] as string;
691+
expect(fetchUrl).toContain("keplr_explicit");
692+
expect(fetchUrl).not.toContain("keplr_from_env");
693+
});
694+
695+
it("should return setup guide when no API key is available", async () => {
696+
mockGetRpcResolver.mockReturnValue({ apiKey: undefined, hasApiKey: false });
697+
698+
const tool = server.getTool("keplr_api_get_usage_summary")!;
699+
const result = await tool.handler({});
700+
const parsed = parseToolResponse(result);
701+
expect(parsed).toHaveProperty("status", "setup_required");
702+
expect(parsed).toHaveProperty("setupGuide");
703+
expect(mockFetch).not.toHaveBeenCalled();
704+
});
705+
});

packages/server/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ const server = new McpServer(
3434
"Before executing any wallet tool for the first time in a session, call `onboarding-status` to check setup progress and guide the user through any remaining steps.\n\n" +
3535
"IMPORTANT: Do NOT guess Cosmos chain IDs — they are often non-obvious " +
3636
"(e.g., 'nyx' for Nym, 'phoenix-1' for Terra, 'columbus-5' for Terra Classic). " +
37-
"Always call list-cosmos-chains to verify the correct chain ID before using any chain-dependent tool.",
37+
"Always call list-cosmos-chains to verify the correct chain ID before using any chain-dependent tool.\n\n" +
38+
"IMPORTANT: The Keplr API key dashboard is at https://api.keplr.app — NEVER guess or infer URLs for Keplr services. " +
39+
"If asked where to get an API key, always direct users to https://api.keplr.app",
3840
},
3941
);
4042

packages/server/src/plugins/keplr-rpc.ts

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
22
import { z } from "zod";
33
import type { SuggestedAction } from "../errors.js";
44
import { classifyError, formatClassifiedError } from "../errors.js";
5+
import { getRpcResolver } from "../rpc/resolver.js";
56
import type { KeplrPlugin } from "./types.js";
67

78
// ─── HTTP Client ─────────────────────────────────────────────────────
@@ -193,17 +194,28 @@ const keplrRpcPlugin: KeplrPlugin = {
193194
name: "keplr-rpc",
194195

195196
register(server, _store) {
197+
/** Resolve API key: explicit param → configured key (env / config file). */
198+
const resolveApiKey = (explicit?: string): string => {
199+
const key = explicit || getRpcResolver().apiKey;
200+
if (!key) {
201+
throw new KeplrApiError(403, "No API key configured");
202+
}
203+
return key;
204+
};
205+
196206
// ── keplr_api_configure_key ────────────────────────────────────────
197207
server.registerTool(
198208
"keplr_api_configure_key",
199209
{
200210
description:
201-
"Configure a Keplr Infra API key. Validates the key, then saves it to the MCP configuration file " +
211+
"Configure a Keplr Infra API key (get one at https://api.keplr.app). Validates the key, then saves it to the MCP configuration file " +
202212
"based on the detected client. Restart required after configuration.",
203213
inputSchema: {
204214
apiKey: z
205215
.string()
206-
.describe("Keplr Infra API key (starts with 'keplr_')"),
216+
.describe(
217+
"Keplr Infra API key from https://api.keplr.app (starts with 'keplr_')",
218+
),
207219
scope: z
208220
.enum(["user", "project"])
209221
.optional()
@@ -482,12 +494,18 @@ const keplrRpcPlugin: KeplrPlugin = {
482494
{
483495
description: "Validate an existing Keplr Infra API key",
484496
inputSchema: {
485-
apiKey: z.string().describe("API key to validate"),
497+
apiKey: z
498+
.string()
499+
.optional()
500+
.describe(
501+
"API key to validate (auto-detected from configured key if omitted)",
502+
),
486503
},
487504
annotations: { readOnlyHint: true },
488505
},
489-
async ({ apiKey }) => {
506+
async ({ apiKey: explicitKey }) => {
490507
try {
508+
const apiKey = resolveApiKey(explicitKey);
491509
const data = await keplrApiFetch<Record<string, unknown>>({
492510
method: "POST",
493511
path: "/v1/keys/validate",
@@ -536,12 +554,16 @@ const keplrRpcPlugin: KeplrPlugin = {
536554
{
537555
description: "Get a Stripe payment link to add credits",
538556
inputSchema: {
539-
apiKey: z.string().describe("API key"),
557+
apiKey: z
558+
.string()
559+
.optional()
560+
.describe("API key (auto-detected from configured key if omitted)"),
540561
},
541562
annotations: { readOnlyHint: true },
542563
},
543-
async ({ apiKey }) => {
564+
async ({ apiKey: explicitKey }) => {
544565
try {
566+
const apiKey = resolveApiKey(explicitKey);
545567
const data = await keplrApiFetch<Record<string, unknown>>({
546568
method: "GET",
547569
path: "/v1/credits/payment-link",
@@ -582,12 +604,16 @@ const keplrRpcPlugin: KeplrPlugin = {
582604
description:
583605
"Get usage summary (balance, requests, credits, per-chain breakdown)",
584606
inputSchema: {
585-
apiKey: z.string().describe("API key"),
607+
apiKey: z
608+
.string()
609+
.optional()
610+
.describe("API key (auto-detected from configured key if omitted)"),
586611
},
587612
annotations: { readOnlyHint: true },
588613
},
589-
async ({ apiKey }) => {
614+
async ({ apiKey: explicitKey }) => {
590615
try {
616+
const apiKey = resolveApiKey(explicitKey);
591617
const raw = await keplrApiFetch<Record<string, unknown>>({
592618
method: "GET",
593619
path: `/v1/usage/${apiKey}/summary`,
@@ -633,7 +659,10 @@ const keplrRpcPlugin: KeplrPlugin = {
633659
description:
634660
"Get usage history with optional date/chain/endpoint filters",
635661
inputSchema: {
636-
apiKey: z.string().describe("API key"),
662+
apiKey: z
663+
.string()
664+
.optional()
665+
.describe("API key (auto-detected from configured key if omitted)"),
637666
startDate: z.string().optional().describe("Start date (ISO format)"),
638667
endDate: z.string().optional().describe("End date (ISO format)"),
639668
chain: z.string().optional().describe("Filter by chain ID"),
@@ -644,8 +673,15 @@ const keplrRpcPlugin: KeplrPlugin = {
644673
},
645674
annotations: { readOnlyHint: true },
646675
},
647-
async ({ apiKey, startDate, endDate, chain, endpointType }) => {
676+
async ({
677+
apiKey: explicitKey,
678+
startDate,
679+
endDate,
680+
chain,
681+
endpointType,
682+
}) => {
648683
try {
684+
const apiKey = resolveApiKey(explicitKey);
649685
const query: Record<string, string> = { clientType: "keplr-mcp" };
650686
if (startDate) query.startDate = startDate;
651687
if (endDate) query.endDate = endDate;
@@ -689,12 +725,16 @@ const keplrRpcPlugin: KeplrPlugin = {
689725
"Get Keplr Infra credit transaction history (top-ups, adjustments). " +
690726
"Use this after payment to verify the exact credit amount added instead of comparing usage summaries.",
691727
inputSchema: {
692-
apiKey: z.string().describe("API key"),
728+
apiKey: z
729+
.string()
730+
.optional()
731+
.describe("API key (auto-detected from configured key if omitted)"),
693732
},
694733
annotations: { readOnlyHint: true },
695734
},
696-
async ({ apiKey }) => {
735+
async ({ apiKey: explicitKey }) => {
697736
try {
737+
const apiKey = resolveApiKey(explicitKey);
698738
const raw = await keplrApiFetch<Record<string, unknown>>({
699739
method: "GET",
700740
path: `/v1/credits/${apiKey}/history`,

packages/server/src/rpc/resolver.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ export class RpcResolver {
3030
return !!this.config.apiKey;
3131
}
3232

33+
/** The configured API key, if any. */
34+
get apiKey(): string | undefined {
35+
return this.config.apiKey;
36+
}
37+
3338
/**
3439
* Resolve the best RPC endpoint for a given chain ID.
3540
* Returns an object compatible with CosmJS HttpEndpoint interface.

0 commit comments

Comments
 (0)