@finrock/mcp 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +7 -0
- package/README.md +214 -2
- package/dist/api/client.d.ts +50 -0
- package/dist/api/client.js +172 -0
- package/dist/api/tools.d.ts +17 -0
- package/dist/api/tools.js +300 -0
- package/dist/docs.d.ts +61 -0
- package/dist/docs.js +190 -0
- package/dist/guides.d.ts +19 -0
- package/dist/guides.js +66 -0
- package/dist/http.d.ts +13 -0
- package/dist/http.js +94 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/server.d.ts +17 -0
- package/dist/server.js +229 -0
- package/dist/setup.d.ts +10 -0
- package/dist/setup.js +123 -0
- package/dist/stdio.d.ts +2 -0
- package/dist/stdio.js +28 -0
- package/dist/webhook.d.ts +11 -0
- package/dist/webhook.js +39 -0
- package/guides/_client/nextjs.md +57 -0
- package/guides/_client/node.md +55 -0
- package/guides/_client/php.md +67 -0
- package/guides/_client/python.md +55 -0
- package/guides/accept-payments/nextjs.md +75 -0
- package/guides/accept-payments/node.md +49 -0
- package/guides/accept-payments/overview.md +30 -0
- package/guides/accept-payments/php.md +61 -0
- package/guides/accept-payments/python.md +59 -0
- package/guides/authentication/overview.md +60 -0
- package/guides/deposit-addresses/nextjs.md +50 -0
- package/guides/deposit-addresses/node.md +30 -0
- package/guides/deposit-addresses/overview.md +36 -0
- package/guides/deposit-addresses/php.md +35 -0
- package/guides/deposit-addresses/python.md +31 -0
- package/guides/payment-widget/html.md +26 -0
- package/guides/payment-widget/nextjs.md +41 -0
- package/guides/payment-widget/overview.md +32 -0
- package/guides/webhooks/nextjs.md +48 -0
- package/guides/webhooks/node.md +48 -0
- package/guides/webhooks/overview.md +34 -0
- package/guides/webhooks/php.md +46 -0
- package/guides/webhooks/python.md +55 -0
- package/guides/withdrawals/nextjs.md +35 -0
- package/guides/withdrawals/node.md +35 -0
- package/guides/withdrawals/overview.md +32 -0
- package/guides/withdrawals/php.md +37 -0
- package/guides/withdrawals/python.md +33 -0
- package/package.json +55 -4
package/dist/http.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Hosted entry point (mcp.finrock.io): stateless Streamable HTTP on POST / (and POST /mcp, the
|
|
4
|
+
* original path, kept so existing client configs keep working).
|
|
5
|
+
* No Finrock credentials are held here; every tool is read-only.
|
|
6
|
+
*/
|
|
7
|
+
import express from "express";
|
|
8
|
+
import { hostHeaderValidation } from "@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js";
|
|
9
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
10
|
+
import { pathToFileURL } from "node:url";
|
|
11
|
+
import { DocsClient } from "./docs.js";
|
|
12
|
+
import { createServer, SERVER_NAME, SERVER_VERSION } from "./server.js";
|
|
13
|
+
/** Hostnames accepted in the Host header when MCP_ALLOWED_HOSTS is unset. */
|
|
14
|
+
export const DEFAULT_ALLOWED_HOSTS = ["mcp.finrock.io", "localhost", "127.0.0.1", "[::1]"];
|
|
15
|
+
/** Parses MCP_ALLOWED_HOSTS: comma-separated hostnames (no ports), or "*" to accept any Host. */
|
|
16
|
+
export function parseAllowedHosts(value) {
|
|
17
|
+
if (value === undefined || value.trim() === "")
|
|
18
|
+
return DEFAULT_ALLOWED_HOSTS;
|
|
19
|
+
const hosts = value.split(",").map((h) => h.trim().toLowerCase()).filter(Boolean);
|
|
20
|
+
return hosts.includes("*") ? undefined : hosts;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* @param opts.allowedHosts Host header allowlist (DNS rebinding protection); undefined accepts any Host.
|
|
24
|
+
*/
|
|
25
|
+
export function createHttpApp(opts = {}) {
|
|
26
|
+
const docs = opts.docs ?? new DocsClient({ baseUrl: process.env.FINROCK_DOCS_URL });
|
|
27
|
+
const app = express();
|
|
28
|
+
// Health probes (Docker, Azure, load balancers) often send an IP or internal name as Host,
|
|
29
|
+
// so /healthz is answered before the Host allowlist.
|
|
30
|
+
app.get("/healthz", (_req, res) => void res.json({ ok: true }));
|
|
31
|
+
if (opts.allowedHosts)
|
|
32
|
+
app.use(hostHeaderValidation(opts.allowedHosts));
|
|
33
|
+
app.use(express.json());
|
|
34
|
+
// Browser-based MCP clients need CORS; the server holds no user data, so any origin is fine.
|
|
35
|
+
app.use((req, res, next) => {
|
|
36
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
37
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
38
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID");
|
|
39
|
+
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, Mcp-Protocol-Version");
|
|
40
|
+
if (req.method === "OPTIONS")
|
|
41
|
+
return void res.sendStatus(204);
|
|
42
|
+
next();
|
|
43
|
+
});
|
|
44
|
+
const handleMcp = async (req, res) => {
|
|
45
|
+
// Stateless: a fresh server and transport per request, sharing the docs cache.
|
|
46
|
+
const server = createServer({ docs });
|
|
47
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
48
|
+
res.on("close", () => {
|
|
49
|
+
void transport.close();
|
|
50
|
+
void server.close();
|
|
51
|
+
});
|
|
52
|
+
try {
|
|
53
|
+
await server.connect(transport);
|
|
54
|
+
await transport.handleRequest(req, res, req.body);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
console.error("MCP request failed", err);
|
|
58
|
+
if (!res.headersSent) {
|
|
59
|
+
res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
app.post(["/", "/mcp"], handleMcp);
|
|
64
|
+
const notAllowed = (_req, res) => void res
|
|
65
|
+
.status(405)
|
|
66
|
+
.set("Allow", "POST")
|
|
67
|
+
.json({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed: this server is stateless" }, id: null });
|
|
68
|
+
// A browser or curl hitting the root gets a short description; an MCP client opening an SSE
|
|
69
|
+
// stream (Accept: text/event-stream) gets 405 like on /mcp, since there is no stream to open.
|
|
70
|
+
app.get("/", (req, res, next) => {
|
|
71
|
+
if (req.accepts(["application/json", "text/event-stream"]) === "text/event-stream")
|
|
72
|
+
return next();
|
|
73
|
+
res.json({
|
|
74
|
+
name: SERVER_NAME,
|
|
75
|
+
version: SERVER_VERSION,
|
|
76
|
+
mcp: "/",
|
|
77
|
+
transport: "streamable-http",
|
|
78
|
+
docs: "https://apidocs.finrock.io",
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
app.get(["/", "/mcp"], notAllowed);
|
|
82
|
+
app.delete(["/", "/mcp"], notAllowed);
|
|
83
|
+
return app;
|
|
84
|
+
}
|
|
85
|
+
const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
86
|
+
if (isMain) {
|
|
87
|
+
const port = Number(process.env.PORT ?? 3000);
|
|
88
|
+
// ALLOWED_HOSTS is the older name, still honoured.
|
|
89
|
+
const allowedHosts = parseAllowedHosts(process.env.MCP_ALLOWED_HOSTS ?? process.env.ALLOWED_HOSTS);
|
|
90
|
+
createHttpApp({ allowedHosts }).listen(port, () => {
|
|
91
|
+
console.log(`Finrock MCP server listening on :${port} (POST / and /mcp)`);
|
|
92
|
+
console.log(allowedHosts ? `Allowed Host headers: ${allowedHosts.join(", ")}` : "Warning: any Host header accepted (MCP_ALLOWED_HOSTS=*)");
|
|
93
|
+
});
|
|
94
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createServer, SERVER_NAME, SERVER_VERSION } from "./server.js";
|
|
2
|
+
export { createHttpApp } from "./http.js";
|
|
3
|
+
export { DocsClient, parseLlmsTxt } from "./docs.js";
|
|
4
|
+
export { buildGuide, listGuides, STACKS } from "./guides.js";
|
|
5
|
+
export { verifyWebhookSignature, FINROCK_WEBHOOK_PUBLIC_KEY } from "./webhook.js";
|
|
6
|
+
export { FinrockClient, FinrockApiError } from "./api/client.js";
|
|
7
|
+
export { registerApiTools, apiToolMode } from "./api/tools.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createServer, SERVER_NAME, SERVER_VERSION } from "./server.js";
|
|
2
|
+
export { createHttpApp } from "./http.js";
|
|
3
|
+
export { DocsClient, parseLlmsTxt } from "./docs.js";
|
|
4
|
+
export { buildGuide, listGuides, STACKS } from "./guides.js";
|
|
5
|
+
export { verifyWebhookSignature, FINROCK_WEBHOOK_PUBLIC_KEY } from "./webhook.js";
|
|
6
|
+
export { FinrockClient, FinrockApiError } from "./api/client.js";
|
|
7
|
+
export { registerApiTools, apiToolMode } from "./api/tools.js";
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { DocsClient } from "./docs.js";
|
|
3
|
+
import { type FinrockClient } from "./api/client.js";
|
|
4
|
+
import { type ApiToolOptions } from "./api/tools.js";
|
|
5
|
+
export declare const SERVER_NAME = "finrock";
|
|
6
|
+
export declare const SERVER_VERSION = "1.0.0";
|
|
7
|
+
export interface ServerDeps {
|
|
8
|
+
docs?: DocsClient;
|
|
9
|
+
guidesDir?: string;
|
|
10
|
+
/** Signed API client built from the user's own credentials (local package only). */
|
|
11
|
+
api?: FinrockClient;
|
|
12
|
+
apiOptions?: ApiToolOptions;
|
|
13
|
+
/** Base URL for Finrock's public endpoints when no API client is given. */
|
|
14
|
+
apiBaseUrl?: string;
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
export declare function createServer(deps?: ServerDeps): McpServer;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { DocsClient } from "./docs.js";
|
|
4
|
+
import { STACKS, STACK_LABELS, buildGuide, listGuides } from "./guides.js";
|
|
5
|
+
import { SETUP_CLIENTS, SETUP_OS, buildAccountSetup } from "./setup.js";
|
|
6
|
+
import { verifyWebhookSignature } from "./webhook.js";
|
|
7
|
+
import { DEFAULT_API_URL, errorMessage, publicGet, withQuery } from "./api/client.js";
|
|
8
|
+
import { describeMode, registerApiTools } from "./api/tools.js";
|
|
9
|
+
export const SERVER_NAME = "finrock";
|
|
10
|
+
export const SERVER_VERSION = "1.0.0";
|
|
11
|
+
const INSTRUCTIONS = `Finrock (https://finrock.io) is a non-custodial MPC wallet platform for accepting, holding and sending crypto. This server helps you add Finrock payments to an application. It reads the live Finrock docs and returns working integration code; it never touches anyone's Finrock account.
|
|
12
|
+
|
|
13
|
+
Suggested workflow:
|
|
14
|
+
1. Pick the integration that fits: call list_integration_guides. Hosted invoices (accept-payments) or the payment widget suit checkouts; deposit-addresses suit balances and top-ups; withdrawals suit payouts; webhooks are needed by almost every integration.
|
|
15
|
+
2. Call get_integration_guide with that goal and the app's stack, and adapt the code to the app's framework, data layer and conventions.
|
|
16
|
+
3. Use search_docs and read_doc for exact request fields, response shapes, asset tickers and edge cases.
|
|
17
|
+
|
|
18
|
+
Rules for any code you write:
|
|
19
|
+
- Finrock API calls must run server-side only. The API key and RSA private key must never reach browser or mobile code, logs, or source control.
|
|
20
|
+
- Sign every request with a fresh RS256 JWT (unique nonce, exp at most 30s after iat, bodyHash of the exact body sent).
|
|
21
|
+
- Verify webhook x-signature against the raw body before parsing, and make handlers idempotent.
|
|
22
|
+
- Only treat a payment as complete after a server-side check (a verified webhook or a fresh API read), never from a browser redirect or callback.`;
|
|
23
|
+
const ACCOUNT_OFF = `
|
|
24
|
+
|
|
25
|
+
Account tools are not enabled on this connection, so you cannot read or change anyone's Finrock account here. To let an agent manage a Finrock account directly (create invoices and addresses, check balances and transactions), run the local package with the account's own credentials. When the user asks for that, offer to set it up and call setup_account_access for their OS and client: it keeps the API key and private key in the OS keychain instead of plain config. Never ask the user to paste either key into the chat.`;
|
|
26
|
+
const accountOn = (mode) => `
|
|
27
|
+
|
|
28
|
+
Account tools are enabled with the user's own Finrock credentials (mode: ${mode}). They act on a real account: confirm with the user before creating anything, and always before any tool that moves funds. Use validate_address before sending to an external address, give every create_transaction a unique sequence_id, and never resubmit a pending transaction with a new sequence_id.`;
|
|
29
|
+
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
30
|
+
const fail = (t) => ({ content: [{ type: "text", text: t }], isError: true });
|
|
31
|
+
export function createServer(deps = {}) {
|
|
32
|
+
const docs = deps.docs ?? new DocsClient();
|
|
33
|
+
const guides = listGuides(deps.guidesDir);
|
|
34
|
+
const goals = guides.map((g) => g.goal);
|
|
35
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION, title: "Finrock" }, { instructions: INSTRUCTIONS + (deps.api ? accountOn(describeMode(deps.apiOptions ?? {})) : ACCOUNT_OFF) });
|
|
36
|
+
server.registerTool("search_docs", {
|
|
37
|
+
title: "Search Finrock docs",
|
|
38
|
+
description: "Full-text search over the live Finrock API documentation (guides, concepts, API reference). Returns the best-matching pages with a snippet; read one in full with read_doc.",
|
|
39
|
+
inputSchema: {
|
|
40
|
+
query: z.string().min(1).describe("What to look for, e.g. 'create invoice fields' or 'XRP destination tag'"),
|
|
41
|
+
limit: z.number().int().min(1).max(20).optional().describe("Maximum results (default 8)"),
|
|
42
|
+
},
|
|
43
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
44
|
+
}, async ({ query, limit }) => {
|
|
45
|
+
try {
|
|
46
|
+
const hits = await docs.search(query, { limit });
|
|
47
|
+
if (hits.length === 0)
|
|
48
|
+
return text(`No Finrock docs pages matched "${query}". Try list_docs to browse.`);
|
|
49
|
+
return text(hits
|
|
50
|
+
.map((h, i) => `${i + 1}. **${h.title}** (\`${h.path}\`)\n ${h.url}\n ${h.snippet}`)
|
|
51
|
+
.join("\n\n"));
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
return fail(`Could not search the Finrock docs: ${errorMessage(err)}`);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
server.registerTool("read_doc", {
|
|
58
|
+
title: "Read a Finrock docs page",
|
|
59
|
+
description: "Return one Finrock documentation page as Markdown, including request/response examples. Accepts a path such as 'api-reference/invoicing/create-invoice' or a full apidocs.finrock.io URL.",
|
|
60
|
+
inputSchema: {
|
|
61
|
+
page: z.string().min(1).describe("Page path from search_docs/list_docs, or a full docs URL"),
|
|
62
|
+
},
|
|
63
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
64
|
+
}, async ({ page }) => {
|
|
65
|
+
try {
|
|
66
|
+
const { url, markdown } = await docs.readPage(page);
|
|
67
|
+
return text(`Source: ${url.replace(/\.md$/, "")}\n\n${markdown}`);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
return fail(`Could not read "${page}": ${errorMessage(err)}. Use search_docs or list_docs to find valid paths.`);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
server.registerTool("list_docs", {
|
|
74
|
+
title: "List Finrock docs pages",
|
|
75
|
+
description: "List every page of the Finrock documentation with its path and one-line description, grouped by section.",
|
|
76
|
+
inputSchema: {
|
|
77
|
+
section: z
|
|
78
|
+
.string()
|
|
79
|
+
.optional()
|
|
80
|
+
.describe("Only this section, e.g. 'guides', 'concepts', 'reference', 'api-reference'"),
|
|
81
|
+
},
|
|
82
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
83
|
+
}, async ({ section }) => {
|
|
84
|
+
try {
|
|
85
|
+
const pages = (await docs.listPages()).filter((p) => !section || p.section === section);
|
|
86
|
+
if (pages.length === 0)
|
|
87
|
+
return text(`No pages in section "${section}".`);
|
|
88
|
+
const bySection = new Map();
|
|
89
|
+
for (const p of pages)
|
|
90
|
+
bySection.set(p.section, [...(bySection.get(p.section) ?? []), p]);
|
|
91
|
+
return text([...bySection]
|
|
92
|
+
.map(([s, ps]) => `## ${s}\n` + ps.map((p) => `- \`${p.path}\`: ${p.title}${p.description ? `. ${p.description}` : ""}`).join("\n"))
|
|
93
|
+
.join("\n\n"));
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
return fail(`Could not load the Finrock docs index: ${errorMessage(err)}`);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
server.registerTool("list_integration_guides", {
|
|
100
|
+
title: "List Finrock integration guides",
|
|
101
|
+
description: "List the ready-made Finrock integration recipes (accept payments, payment widget, deposit addresses, webhooks, withdrawals, authentication) and the stacks they support.",
|
|
102
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
103
|
+
}, async () => text(guides.map((g) => `- **${g.goal}**: ${g.title}. ${g.summary}`).join("\n") +
|
|
104
|
+
`\n\nStacks: ${STACKS.map((s) => `\`${s}\` (${STACK_LABELS[s]})`).join(", ")}.` +
|
|
105
|
+
"\nCall get_integration_guide with a goal and stack."));
|
|
106
|
+
server.registerTool("get_integration_guide", {
|
|
107
|
+
title: "Get a Finrock integration guide",
|
|
108
|
+
description: "Step-by-step instructions plus production-ready code to add a Finrock capability to an app in a given stack, including the request-signing client where needed. Adapt names and data access to the target codebase.",
|
|
109
|
+
inputSchema: {
|
|
110
|
+
goal: z.enum(goals).describe("Which integration to build; see list_integration_guides"),
|
|
111
|
+
stack: z.enum(STACKS).default("node").describe("Target stack of the app"),
|
|
112
|
+
},
|
|
113
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
114
|
+
}, async ({ goal, stack }) => {
|
|
115
|
+
try {
|
|
116
|
+
return text(buildGuide(goal, stack, docs.baseUrl, deps.guidesDir));
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
return fail(errorMessage(err));
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
server.registerTool("verify_webhook_signature", {
|
|
123
|
+
title: "Verify a Finrock webhook signature",
|
|
124
|
+
description: "Check whether a captured Finrock webhook body and its x-signature header verify against Finrock's published webhook key. Use it to debug a webhook handler that rejects deliveries.",
|
|
125
|
+
inputSchema: {
|
|
126
|
+
raw_body: z.string().describe("The exact raw request body as received, before any JSON parsing"),
|
|
127
|
+
signature: z.string().describe("The x-signature header value (base64)"),
|
|
128
|
+
},
|
|
129
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
130
|
+
}, async ({ raw_body, signature }) => {
|
|
131
|
+
const r = verifyWebhookSignature(raw_body, signature);
|
|
132
|
+
return text(r.valid ? "Valid: this body was signed by Finrock." : `Invalid. ${r.reason}`);
|
|
133
|
+
});
|
|
134
|
+
server.registerTool("setup_account_access", {
|
|
135
|
+
title: "Set up Finrock account access securely",
|
|
136
|
+
description: "Step-by-step setup for the local package's account tools with the Finrock API key and RSA private key stored in the OS keychain (macOS Keychain, Linux Secret Service, Windows DPAPI) rather than in plain MCP config. Use it when the user wants the agent to work with their own Finrock account.",
|
|
137
|
+
inputSchema: {
|
|
138
|
+
os: z.enum(SETUP_OS).describe("The user's operating system"),
|
|
139
|
+
client: z
|
|
140
|
+
.enum(SETUP_CLIENTS)
|
|
141
|
+
.default("json-config")
|
|
142
|
+
.describe("claude-code for the `claude mcp add` CLI; json-config for Claude Desktop, Cursor and other clients with an mcpServers JSON file"),
|
|
143
|
+
},
|
|
144
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
145
|
+
}, async ({ os, client }) => text(buildAccountSetup(os, client)));
|
|
146
|
+
const apiBase = deps.api?.baseUrl ?? deps.apiBaseUrl ?? DEFAULT_API_URL;
|
|
147
|
+
const getPublic = (path, query) => publicGet(apiBase, withQuery(path, query), deps.fetch);
|
|
148
|
+
server.registerTool("list_supported_assets", {
|
|
149
|
+
title: "List supported assets",
|
|
150
|
+
description: "Finrock's supported coins and tokens with the exact asset name to use in API calls (e.g. USDT_TRX), network, decimals, confirmations and whether a destination tag is needed.",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
search: z.string().optional().describe("Filter by name, ticker or network, e.g. 'usdt' or 'tron'"),
|
|
153
|
+
network: z.enum(["live", "testnet"]).default("live"),
|
|
154
|
+
},
|
|
155
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
156
|
+
}, async ({ search, network }) => {
|
|
157
|
+
try {
|
|
158
|
+
const data = await getPublic("/assets");
|
|
159
|
+
const list = (network === "testnet" ? data.testNetAssets : data.liveNetAssets) ?? [];
|
|
160
|
+
const q = search?.toLowerCase();
|
|
161
|
+
const rows = list
|
|
162
|
+
.filter((a) => !q || ["asset_name", "ticker", "network", "parent_blockchain", "blockchain_type"].some((k) => String(a[k] ?? "").toLowerCase().includes(q)))
|
|
163
|
+
.map((a) => ({
|
|
164
|
+
asset_name: a.asset_name,
|
|
165
|
+
ticker: a.ticker,
|
|
166
|
+
network: a.network,
|
|
167
|
+
blockchain: a.parent_blockchain,
|
|
168
|
+
decimals: a.divisibility,
|
|
169
|
+
contract_address: a.contract_address || undefined,
|
|
170
|
+
required_confirmations: a.required_confirmations,
|
|
171
|
+
needs_address_tag: a.address_tag,
|
|
172
|
+
price_in_usd: a.price_in_usd,
|
|
173
|
+
}));
|
|
174
|
+
return text(rows.length ? JSON.stringify(rows, null, 2) : `No ${network} assets matched "${search}".`);
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
return fail(`Could not load supported assets: ${errorMessage(err)}`);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
server.registerTool("get_asset_price", {
|
|
181
|
+
title: "Get a crypto spot price",
|
|
182
|
+
description: "USD spot price and 24h change from Finrock. Pass a ticker (e.g. 'btc', 'usdt') for every network it trades on, or an asset name (e.g. 'USDT_TRX') for one.",
|
|
183
|
+
inputSchema: {
|
|
184
|
+
crypto: z.string().min(1).default("btc").describe("Ticker or asset name"),
|
|
185
|
+
},
|
|
186
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
187
|
+
}, async ({ crypto }) => {
|
|
188
|
+
try {
|
|
189
|
+
// /prices ignores query parameters and returns every asset, keyed by lower-case asset name, in USD only.
|
|
190
|
+
const data = await getPublic("/prices");
|
|
191
|
+
const q = crypto.toLowerCase();
|
|
192
|
+
const rows = Object.entries(data?.usd ?? {})
|
|
193
|
+
.filter(([id, p]) => id === q || p.ticker?.toLowerCase() === q)
|
|
194
|
+
.map(([id, { sparkline_in_7d: _, ...p }]) => ({ asset: id.toUpperCase(), ...p }));
|
|
195
|
+
if (!rows.length)
|
|
196
|
+
return fail(`No price for "${crypto}". Use list_supported_assets for valid tickers and asset names.`);
|
|
197
|
+
return text(JSON.stringify(rows, null, 2));
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
return fail(`Could not get the price: ${errorMessage(err)}`);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
if (deps.api)
|
|
204
|
+
registerApiTools(server, deps.api, deps.apiOptions);
|
|
205
|
+
server.registerPrompt("integrate_finrock", {
|
|
206
|
+
title: "Add Finrock crypto payments to this app",
|
|
207
|
+
description: "Plan and implement a Finrock integration in the current codebase.",
|
|
208
|
+
argsSchema: {
|
|
209
|
+
use_case: z.string().optional().describe("What the app needs, e.g. 'checkout for a SaaS plan' or 'user wallet top-ups'"),
|
|
210
|
+
},
|
|
211
|
+
}, ({ use_case }) => ({
|
|
212
|
+
messages: [
|
|
213
|
+
{
|
|
214
|
+
role: "user",
|
|
215
|
+
content: {
|
|
216
|
+
type: "text",
|
|
217
|
+
text: `Add Finrock crypto payments to this application${use_case ? ` for: ${use_case}` : ""}.
|
|
218
|
+
|
|
219
|
+
1. Inspect the codebase to identify its stack, framework, data layer and where payments belong.
|
|
220
|
+
2. Call list_integration_guides, choose the right goal(s) (almost always including webhooks), and call get_integration_guide for this stack.
|
|
221
|
+
3. Use search_docs/read_doc to confirm exact fields for every endpoint you call.
|
|
222
|
+
4. Implement it following the codebase's conventions: server-side API client, environment variables (FINROCK_API_KEY, FINROCK_PRIVATE_KEY), routes, persistence and an idempotent webhook handler.
|
|
223
|
+
5. List the manual steps I must do in the Finrock dashboard (API user and public key upload, webhook URL, widget ID) and how to test end to end.`,
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
}));
|
|
228
|
+
return server;
|
|
229
|
+
}
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step-by-step setup for the local package's account tools, with the API key and private key
|
|
3
|
+
* kept in the OS keychain (or a secret manager) and read through FINROCK_*_COMMAND. Nothing
|
|
4
|
+
* here touches credentials: it only returns instructions for the user's own machine.
|
|
5
|
+
*/
|
|
6
|
+
export declare const SETUP_OS: readonly ["macos", "linux", "windows"];
|
|
7
|
+
export type SetupOs = (typeof SETUP_OS)[number];
|
|
8
|
+
export declare const SETUP_CLIENTS: readonly ["claude-code", "json-config"];
|
|
9
|
+
export type SetupClient = (typeof SETUP_CLIENTS)[number];
|
|
10
|
+
export declare function buildAccountSetup(os: SetupOs, client: SetupClient): string;
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step-by-step setup for the local package's account tools, with the API key and private key
|
|
3
|
+
* kept in the OS keychain (or a secret manager) and read through FINROCK_*_COMMAND. Nothing
|
|
4
|
+
* here touches credentials: it only returns instructions for the user's own machine.
|
|
5
|
+
*/
|
|
6
|
+
export const SETUP_OS = ["macos", "linux", "windows"];
|
|
7
|
+
export const SETUP_CLIENTS = ["claude-code", "json-config"];
|
|
8
|
+
const KEYGEN = `openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out finrock_private.pem
|
|
9
|
+
openssl pkey -in finrock_private.pem -pubout -out finrock_public.pem`;
|
|
10
|
+
/**
|
|
11
|
+
* PowerShell that decrypts a DPAPI file under %USERPROFILE%\.finrock, passed as -EncodedCommand
|
|
12
|
+
* so the command needs no quoting in cmd.exe, PowerShell or JSON.
|
|
13
|
+
*/
|
|
14
|
+
function dpapiRead(name) {
|
|
15
|
+
const script = `[Net.NetworkCredential]::new('', (Get-Content (Join-Path $env:USERPROFILE '.finrock\\${name}') | ConvertTo-SecureString)).Password`;
|
|
16
|
+
return `powershell -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(script, "utf16le").toString("base64")}`;
|
|
17
|
+
}
|
|
18
|
+
const STEPS = {
|
|
19
|
+
macos: {
|
|
20
|
+
label: "macOS (Keychain)",
|
|
21
|
+
shell: "bash",
|
|
22
|
+
// `security -w` hex-encodes values containing newlines, so store the PEM as one line with \n escapes.
|
|
23
|
+
storeKey: `security add-generic-password -U -s finrock-private-key -a finrock -w "$(awk '{printf "%s\\\\n", $0}' finrock_private.pem)"
|
|
24
|
+
rm finrock_private.pem`,
|
|
25
|
+
storeApiKey: `security add-generic-password -U -s finrock-api-key -a finrock -w`,
|
|
26
|
+
apiKeyCommand: `/usr/bin/security find-generic-password -s finrock-api-key -w`,
|
|
27
|
+
privateKeyCommand: `/usr/bin/security find-generic-password -s finrock-private-key -w`,
|
|
28
|
+
notes: "The first time the server starts, macOS asks whether `security` may read each item. Choose **Always Allow**, or **Allow** to be asked again on every start.",
|
|
29
|
+
},
|
|
30
|
+
linux: {
|
|
31
|
+
label: "Linux (Secret Service: GNOME Keyring or KWallet)",
|
|
32
|
+
shell: "bash",
|
|
33
|
+
storeKey: `secret-tool store --label="Finrock private key" service finrock-mcp key private-key < finrock_private.pem
|
|
34
|
+
rm finrock_private.pem`,
|
|
35
|
+
storeApiKey: `secret-tool store --label="Finrock API key" service finrock-mcp key api-key`,
|
|
36
|
+
apiKeyCommand: `secret-tool lookup service finrock-mcp key api-key`,
|
|
37
|
+
privateKeyCommand: `secret-tool lookup service finrock-mcp key private-key`,
|
|
38
|
+
notes: "`secret-tool` comes with libsecret (`sudo apt install libsecret-tools` or `sudo dnf install libsecret`) and needs an unlocked desktop keyring. On a headless machine without one, keep the key file instead: `chmod 600 finrock_private.pem` and set `FINROCK_PRIVATE_KEY_PATH` to its absolute path.",
|
|
39
|
+
},
|
|
40
|
+
windows: {
|
|
41
|
+
label: "Windows (DPAPI, encrypted to your Windows account)",
|
|
42
|
+
shell: "powershell",
|
|
43
|
+
storeKey: `New-Item -ItemType Directory -Force "$env:USERPROFILE\\.finrock" | Out-Null
|
|
44
|
+
Get-Content -Raw finrock_private.pem | ConvertTo-SecureString -AsPlainText -Force | ConvertFrom-SecureString | Set-Content "$env:USERPROFILE\\.finrock\\private-key"
|
|
45
|
+
Remove-Item finrock_private.pem`,
|
|
46
|
+
storeApiKey: `Read-Host "Finrock API Key UUID" -AsSecureString | ConvertFrom-SecureString | Set-Content "$env:USERPROFILE\\.finrock\\api-key"`,
|
|
47
|
+
apiKeyCommand: dpapiRead("api-key"),
|
|
48
|
+
privateKeyCommand: dpapiRead("private-key"),
|
|
49
|
+
notes: "The files under `%USERPROFILE%\\.finrock` are encrypted with Windows DPAPI and can only be decrypted by your Windows account on this machine. The two read commands are PowerShell passed with `-EncodedCommand` so they need no quoting; each decodes to `[Net.NetworkCredential]::new('', (Get-Content (Join-Path $env:USERPROFILE '.finrock\\<name>') | ConvertTo-SecureString)).Password`. `openssl` ships with Git for Windows (run step 1 in Git Bash if PowerShell can't find it).",
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
const json = (v) => JSON.stringify(v, null, 2);
|
|
53
|
+
/** Single-quote for a POSIX shell; also valid in PowerShell for text without quotes. */
|
|
54
|
+
const sq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
55
|
+
function clientConfig(os, client, s) {
|
|
56
|
+
if (client === "claude-code") {
|
|
57
|
+
const shell = os === "windows" ? "powershell" : "bash";
|
|
58
|
+
const nl = os === "windows" ? " `" : " \\";
|
|
59
|
+
return `\`\`\`${shell}
|
|
60
|
+
claude mcp add finrock --scope user${nl}
|
|
61
|
+
-e FINROCK_API_KEY_COMMAND=${sq(s.apiKeyCommand)}${nl}
|
|
62
|
+
-e FINROCK_PRIVATE_KEY_COMMAND=${sq(s.privateKeyCommand)}${nl}
|
|
63
|
+
-- npx -y @finrock/mcp
|
|
64
|
+
\`\`\``;
|
|
65
|
+
}
|
|
66
|
+
const config = {
|
|
67
|
+
mcpServers: {
|
|
68
|
+
finrock: {
|
|
69
|
+
command: "npx",
|
|
70
|
+
args: ["-y", "@finrock/mcp"],
|
|
71
|
+
env: { FINROCK_API_KEY_COMMAND: s.apiKeyCommand, FINROCK_PRIVATE_KEY_COMMAND: s.privateKeyCommand },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
return `Add this to the client's MCP config (for Claude Desktop: Settings, Developer, Edit Config), then restart the client:
|
|
76
|
+
|
|
77
|
+
\`\`\`json
|
|
78
|
+
${json(config)}
|
|
79
|
+
\`\`\``;
|
|
80
|
+
}
|
|
81
|
+
export function buildAccountSetup(os, client) {
|
|
82
|
+
const s = STEPS[os];
|
|
83
|
+
const fence = (code) => `\`\`\`${s.shell}\n${code}\n\`\`\``;
|
|
84
|
+
return `# Connect a Finrock account, keys in ${s.label}
|
|
85
|
+
|
|
86
|
+
The account tools run in the local package (\`npx @finrock/mcp\`) on the user's machine, which signs every request there. The hosted server at mcp.finrock.io never receives credentials. With this setup, neither key sits in plain text in the MCP config: the package reads each one from the keychain when it starts and keeps it only in memory.
|
|
87
|
+
|
|
88
|
+
**Rules for the agent:** never ask the user to paste the private key or the API key into the chat, and never print, log or commit either one. You may run step 1 for the user if you have a terminal, but step 3 prompts for a secret, so the user must run it in their own terminal (in Claude Code, prefix it with \`!\`).
|
|
89
|
+
|
|
90
|
+
## 1. Generate the key pair
|
|
91
|
+
|
|
92
|
+
Run this in a private directory. Keep \`finrock_public.pem\`; the private key goes into the keychain in step 2.
|
|
93
|
+
|
|
94
|
+
${fence(KEYGEN)}
|
|
95
|
+
|
|
96
|
+
## 2. Store the private key and delete the file
|
|
97
|
+
|
|
98
|
+
${fence(s.storeKey)}
|
|
99
|
+
|
|
100
|
+
## 3. Create the API user and store its key
|
|
101
|
+
|
|
102
|
+
In the Finrock Dashboard, open **API Users**, create a dedicated user for the agent, upload \`finrock_public.pem\`, restrict it to your IP, and copy the **API Key UUID** (shown only once). Then run this and paste the UUID at the prompt, not in the chat:
|
|
103
|
+
|
|
104
|
+
${fence(s.storeApiKey)}
|
|
105
|
+
|
|
106
|
+
## 4. Point the MCP client at the keychain
|
|
107
|
+
|
|
108
|
+
${clientConfig(os, client, s)}
|
|
109
|
+
|
|
110
|
+
Add \`FINROCK_READ_ONLY=true\` to expose read tools only, or \`FINROCK_ENABLE_SENSITIVE_TOOLS=true\` to also allow tools that move funds. Leave both out for read and create tools.
|
|
111
|
+
|
|
112
|
+
## 5. Check it
|
|
113
|
+
|
|
114
|
+
Restart the client. The server's log should say \`finrock-mcp: account tools on\`; ask the agent to call \`list_wallets\`. On HTTP 401, check that the uploaded public key matches the stored private key and that the machine's clock is correct.
|
|
115
|
+
|
|
116
|
+
## Notes
|
|
117
|
+
|
|
118
|
+
- ${s.notes}
|
|
119
|
+
- With a password manager CLI, set the same variables to its read command instead, for example \`FINROCK_PRIVATE_KEY_COMMAND="op read op://Private/Finrock/private_key"\` for 1Password.
|
|
120
|
+
- To rotate, generate a new pair, upload the new public key, and repeat steps 2 and 3 (they overwrite the stored items).
|
|
121
|
+
- Claude on the web and mobile can't run local servers; use Claude Code, Claude Desktop or another client that supports stdio servers.
|
|
122
|
+
- In the user's own application, store the same kind of credentials in that app's server-side secret store (environment variables, a cloud secret manager or KMS), never in browser code or the repository.`;
|
|
123
|
+
}
|
package/dist/stdio.d.ts
ADDED
package/dist/stdio.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Local entry point: `npx @finrock/mcp` speaks MCP over stdio. With FINROCK_API_KEY and
|
|
4
|
+
* FINROCK_PRIVATE_KEY (or FINROCK_PRIVATE_KEY_PATH, or *_COMMAND variants that read them from a
|
|
5
|
+
* keychain) set, it also exposes account tools
|
|
6
|
+
* that sign requests on this machine; the key never leaves it.
|
|
7
|
+
*/
|
|
8
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
|
+
import { DocsClient } from "./docs.js";
|
|
10
|
+
import { FinrockClient } from "./api/client.js";
|
|
11
|
+
import { apiToolMode, describeMode } from "./api/tools.js";
|
|
12
|
+
import { createServer } from "./server.js";
|
|
13
|
+
let api;
|
|
14
|
+
try {
|
|
15
|
+
api = FinrockClient.fromEnv();
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
console.error(`finrock-mcp: account tools disabled: ${err.message}`);
|
|
19
|
+
}
|
|
20
|
+
const apiOptions = apiToolMode();
|
|
21
|
+
console.error(api ? `finrock-mcp: account tools on, ${describeMode(apiOptions)}` : "finrock-mcp: docs and guides only (no FINROCK_API_KEY)");
|
|
22
|
+
const server = createServer({
|
|
23
|
+
docs: new DocsClient({ baseUrl: process.env.FINROCK_DOCS_URL }),
|
|
24
|
+
api,
|
|
25
|
+
apiOptions,
|
|
26
|
+
apiBaseUrl: process.env.FINROCK_API_URL,
|
|
27
|
+
});
|
|
28
|
+
await server.connect(new StdioServerTransport());
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finrock's public key for webhook signatures, as published at
|
|
3
|
+
* https://apidocs.finrock.io/guides/webhooks. Each delivery carries an
|
|
4
|
+
* `x-signature` header: base64 RSA-SHA512 (PKCS#1 v1.5) over the raw body.
|
|
5
|
+
*/
|
|
6
|
+
export declare const FINROCK_WEBHOOK_PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----\nMIIBnzANBgkqhkiG9w0BAQEFAAOCAYwAMIIBhwKCAX4Aq1O7ztZxzkgDCFlUnHvs\nEBffqmlng9uEmNMCGbCpxB7991WiR4TRRVaTUrLxIzPWl79xT6R2Xzba1GqN2bin\nsPBU/cmHEF5v1bDopZ7oHnU43tnlpH1kSOjtvTBORf6v6+9xh87OYtg4wXuwVzCq\nykXDRPIIzgPCSpA7l7DtPqhOpm0OVpUnVJQ5DgnsMfJMPkrrPTjRkpFZ1RbtqoLW\nUI+bMWZAAKLEsh4Q+SRO7xiHj26gToD+/ZrEAbC/SOcy2ChDfpQ39XN/bYTu52j8\nndAS/R4qnffLGpmTcxr19UC+BPs5Wb5kx2ZJsGPQPpoPHaTBC1n3YwQlksgeHEvX\nZBsDoAvIXpGfBjUPzd8bvz5vyxbLYA4lSqp/Fw48NYgRRgfOWjmtqVI07jnXKxU2\nkcQc7QkoFVDYw45DS14tkrsCOJjMF0qux2dL9Gr1z/Xdaod+SyeRqsTDYhbzaD+v\nUkO6chtsIHmQwAF8jYajAR7uKROX+6f8ZIwYDco3AgMBAAE=\n-----END PUBLIC KEY-----";
|
|
7
|
+
export interface WebhookCheck {
|
|
8
|
+
valid: boolean;
|
|
9
|
+
reason?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function verifyWebhookSignature(rawBody: string, signatureB64: string, publicKeyPem?: string): WebhookCheck;
|
package/dist/webhook.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createPublicKey, verify, constants } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Finrock's public key for webhook signatures, as published at
|
|
4
|
+
* https://apidocs.finrock.io/guides/webhooks. Each delivery carries an
|
|
5
|
+
* `x-signature` header: base64 RSA-SHA512 (PKCS#1 v1.5) over the raw body.
|
|
6
|
+
*/
|
|
7
|
+
export const FINROCK_WEBHOOK_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
|
|
8
|
+
MIIBnzANBgkqhkiG9w0BAQEFAAOCAYwAMIIBhwKCAX4Aq1O7ztZxzkgDCFlUnHvs
|
|
9
|
+
EBffqmlng9uEmNMCGbCpxB7991WiR4TRRVaTUrLxIzPWl79xT6R2Xzba1GqN2bin
|
|
10
|
+
sPBU/cmHEF5v1bDopZ7oHnU43tnlpH1kSOjtvTBORf6v6+9xh87OYtg4wXuwVzCq
|
|
11
|
+
ykXDRPIIzgPCSpA7l7DtPqhOpm0OVpUnVJQ5DgnsMfJMPkrrPTjRkpFZ1RbtqoLW
|
|
12
|
+
UI+bMWZAAKLEsh4Q+SRO7xiHj26gToD+/ZrEAbC/SOcy2ChDfpQ39XN/bYTu52j8
|
|
13
|
+
ndAS/R4qnffLGpmTcxr19UC+BPs5Wb5kx2ZJsGPQPpoPHaTBC1n3YwQlksgeHEvX
|
|
14
|
+
ZBsDoAvIXpGfBjUPzd8bvz5vyxbLYA4lSqp/Fw48NYgRRgfOWjmtqVI07jnXKxU2
|
|
15
|
+
kcQc7QkoFVDYw45DS14tkrsCOJjMF0qux2dL9Gr1z/Xdaod+SyeRqsTDYhbzaD+v
|
|
16
|
+
UkO6chtsIHmQwAF8jYajAR7uKROX+6f8ZIwYDco3AgMBAAE=
|
|
17
|
+
-----END PUBLIC KEY-----`;
|
|
18
|
+
export function verifyWebhookSignature(rawBody, signatureB64, publicKeyPem = FINROCK_WEBHOOK_PUBLIC_KEY) {
|
|
19
|
+
const sig = signatureB64.trim();
|
|
20
|
+
if (!sig)
|
|
21
|
+
return { valid: false, reason: "The x-signature value is empty." };
|
|
22
|
+
if (!/^[A-Za-z0-9+/]+=*$/.test(sig)) {
|
|
23
|
+
return { valid: false, reason: "The x-signature value is not base64." };
|
|
24
|
+
}
|
|
25
|
+
let key;
|
|
26
|
+
try {
|
|
27
|
+
key = createPublicKey(publicKeyPem);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
return { valid: false, reason: `Could not load the public key: ${err.message}` };
|
|
31
|
+
}
|
|
32
|
+
const ok = verify("sha512", Buffer.from(rawBody, "utf8"), { key, padding: constants.RSA_PKCS1_PADDING }, Buffer.from(sig, "base64"));
|
|
33
|
+
if (ok)
|
|
34
|
+
return { valid: true };
|
|
35
|
+
return {
|
|
36
|
+
valid: false,
|
|
37
|
+
reason: "Signature does not match this body. Verify against the exact raw bytes Finrock sent, captured before any JSON parsing (for example express.raw(), not express.json()), and pass the x-signature header unchanged.",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
### Finrock API client (`lib/finrock.ts`)
|
|
2
|
+
|
|
3
|
+
No SDK needed: this signs each request with Node's built-in `crypto`. Use it only from
|
|
4
|
+
Route Handlers, Server Actions or Server Components on the Node.js runtime.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
// lib/finrock.ts
|
|
8
|
+
import "server-only"; // npm i server-only — fails the build if imported client-side
|
|
9
|
+
import { createHash, createSign, randomUUID } from "node:crypto";
|
|
10
|
+
|
|
11
|
+
const API_BASE = "https://api.finrock.io";
|
|
12
|
+
const API_KEY = process.env.FINROCK_API_KEY!; // API Key UUID from Finrock Dashboard → API Users
|
|
13
|
+
// PEM of the RSA-4096 private key whose public half you uploaded. Env vars often
|
|
14
|
+
// store newlines as "\n", so restore them.
|
|
15
|
+
const PRIVATE_KEY = process.env.FINROCK_PRIVATE_KEY!.replace(/\\n/g, "\n");
|
|
16
|
+
|
|
17
|
+
const b64url = (b: Buffer | string) => Buffer.from(b).toString("base64url");
|
|
18
|
+
|
|
19
|
+
/** A fresh RS256 JWT for one request. Finrock rejects reused nonces and exp > iat + 30s. */
|
|
20
|
+
function signJwt(uri: string, rawBody: string): string {
|
|
21
|
+
const now = Math.floor(Date.now() / 1000);
|
|
22
|
+
const header = { alg: "RS256", typ: "JWT" };
|
|
23
|
+
const payload = {
|
|
24
|
+
uri, // request path, e.g. /dac/v1/invoice
|
|
25
|
+
nonce: randomUUID(),
|
|
26
|
+
iat: now,
|
|
27
|
+
exp: now + 25,
|
|
28
|
+
sub: API_KEY,
|
|
29
|
+
bodyHash: createHash("sha256").update(rawBody).digest("hex"),
|
|
30
|
+
};
|
|
31
|
+
const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
|
|
32
|
+
const signature = createSign("RSA-SHA256").update(input).sign(PRIVATE_KEY);
|
|
33
|
+
return `${input}.${b64url(signature)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function finrock<T = any>(
|
|
37
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
|
|
38
|
+
path: string,
|
|
39
|
+
body?: unknown,
|
|
40
|
+
): Promise<T> {
|
|
41
|
+
const rawBody = body === undefined ? "" : JSON.stringify(body);
|
|
42
|
+
const res = await fetch(API_BASE + path, {
|
|
43
|
+
method,
|
|
44
|
+
headers: {
|
|
45
|
+
authorization: `Bearer ${signJwt(path, rawBody)}`,
|
|
46
|
+
"x-api-key": API_KEY,
|
|
47
|
+
accept: "application/json",
|
|
48
|
+
...(rawBody ? { "content-type": "application/json" } : {}),
|
|
49
|
+
},
|
|
50
|
+
body: rawBody || undefined,
|
|
51
|
+
cache: "no-store",
|
|
52
|
+
});
|
|
53
|
+
const text = await res.text();
|
|
54
|
+
if (!res.ok) throw new Error(`Finrock ${method} ${path} -> HTTP ${res.status}: ${text}`);
|
|
55
|
+
return (text ? JSON.parse(text) : null) as T;
|
|
56
|
+
}
|
|
57
|
+
```
|