FirmLedger
Search and access verified, source-backed company and business intelligence through FirmLedger.
Hosted MCP Server
npx add-mcp 'https://firmledger.co.ke/mcp'Installs into Claude Code, Codex, Cursor and more
Documentation
A Model Context Protocol server for the FirmLedger ledger. It has 51 tools: read-only directory lookups, reads of the key owner’s own account, account changes that always need the user’s explicit confirmation, and checkout links that never charge anything. It follows the website’s permission model exactly. Setting it up in an AI app? Start with the plain-English setup guide.
On this page
Endpoint & transport Authentication Sign-in (OAuth 2.1) Access levels & scopes Confirmation handshake Checkout links Result format Tools (51) Open tools (15) Pro directory tools (4) Account reads (12) Account changes (20) Errors Limits Safety guarantees Raw examples
Primary AI connection address: https://mcp.firmledger.co.ke/mcp. This DNS-only subdomain bypasses Cloudflare and reaches the same application. The old https://firmledger.co.ke/mcp remains active, but its Cloudflare security layer currently challenges most automated AI clients. Update client configurations and registry entries to the new address; keep your existing key and permissions. Why there are two front doors to the same server.
Endpoint & transport
| Public URL | https://mcp.firmledger.co.ke/mcp (or https://mcp.firmledger.co.ke/mcp/<api_key>, see below). It stays open for anonymous directory access and existing member connections. |
|---|---|
| Separate User MCP | https://mcp.firmledger.co.ke/user-mcp requires a member API key or an OAuth token issued for this exact resource. It returns a sign-in challenge instead of anonymous access; its OAuth grant is resource-bound and separate from public MCP sign-ins. |
| Transport | Streamable HTTP, stateless. POST handles JSON-RPC 2.0 and answers as application/json; GET opens the standalone server-event stream as text/event-stream (including clients that send no Accept header or a wildcard). There are no sessions, so no Mcp-Session-Id is issued. A browser (Accept: text/html) is redirected to the setup guide. The older, separate SSE transport is not used. |
| Capabilities | tools only, plus server instructions that tell the model how the confirmation and checkout rules work. Tool annotations are accurate: directory and account-read tools carry readOnlyHint: true. Account changes carry readOnlyHint: false with per-tool destructiveHint / idempotentHint, so clients that ask before side effects (ChatGPT, VS Code and others) do so. |
| Built on | The official @modelcontextprotocol/sdk, mounted inside the FirmLedger web app. Server name firmledger. |
| CORS | Access-Control-Allow-Origin: *. Authentication is by key, never by cookie, so browser-based clients work and a browser session can never be ridden. |
| Caching | Cache-Control: no-store, X-Robots-Tag: noindex. Every response carries X-Request-Id. |
Authentication
Keys are ordinary FirmLedger API keys (fl_live_ + 32 characters), created at /dashboard/api. Every key needs the read:listings scope. Send it one of three ways:
preferredAuthorization: Bearer fl_live_your_key # X-API-Key: fl_live_your_key ← accepted alternative # POST https://mcp.firmledger.co.ke/mcp/fl\_live\_your\_key ← personal connector URL
Why a key-in-path URL exists. The REST API refuses keys in URLs. The MCP endpoint accepts one because ChatGPT’s custom connectors can only use OAuth or no authentication (no custom headers), and Claude’s header option is a limited beta. The personal URL is therefore the one method that works in every consumer app. The trade-off is that the URL is a bearer credential. Treat it like a password and revoke the key if it leaks. The server never echoes the key in a response, redirect or error. Use the header wherever the client supports one.
| No key | Allowed. The caller is free with authenticated: false and gets exactly what a signed-out visitor sees on the website. |
|---|---|
| Valid key | The caller is pro if the key’s account has Pro (paid or trial), otherwise free. This is decided live on every request, so a lapsed plan drops to free immediately and a renewal restores Pro with the same key. |
| Unknown / malformed key | HTTP 401 with WWW-Authenticate: Bearer. It is never silently downgraded to Free access, so a mistyped key fails loudly. Failures count toward the per-IP brute-force lock shared with the REST API. |
| Revoked key | HTTP 401, reason: key_revoked |
| Suspended account | HTTP 403, reason: account_suspended |
Key without read:listings | HTTP 403, reason: insufficient_scope |
Sign-in (OAuth 2.1)
An alternative to API keys. An assistant that signs the member in receives a bearer token for the member’s own account. The public endpoint does not change: https://mcp.firmledger.co.ke/mcp, with no key. Clients that do not support sign-in keep using the key-based methods above.
| Protected-resource metadata | GET https://mcp.firmledger.co.ke/.well-known/oauth-protected-resource/mcp (RFC 9728). Names the resource (https://mcp.firmledger.co.ke/mcp) and the authorization server. |
|---|---|
| Sign-in-only address | https://mcp.firmledger.co.ke/mcp/sign-in answers every request, the handshake included, with the 401 sign-in challenge until a sign-in token is presented — GET included. A browser opening the address gets the same 401 as an HTML sign-in page (with the WWW-Authenticate challenge), never a redirect, so a client that probes the address with GET cannot mistake it for connected. API keys are refused there with reason: wrong_endpoint and the key-based addresses named; moderator and admin keys get wrong_surface. Its metadata is https://mcp.firmledger.co.ke/.well-known/oauth-protected-resource/mcp/sign-in, which names https://mcp.firmledger.co.ke/mcp/sign-in as the resource. The open address is unchanged. Use the sign-in address for an assistant that records a connector as connected as soon as the open address answers, so it never starts sign-in. API clients without a browser popup (Groq, scripts, code) must use a key-based URL instead. |
| Authorization-server metadata | GET https://mcp.firmledger.co.ke/.well-known/oauth-authorization-server (RFC 8414). |
| Client registration | POST https://mcp.firmledger.co.ke/oauth/register (RFC 7591), JSON. Public clients only (token_endpoint_auth_method: "none"). Redirect addresses must be https, loopback http (localhost or 127.0.0.1, any port: RFC 8252) or an app scheme such as cursor://. Fragments are refused. Up to 10 addresses per client, and 30 registrations per network address per hour. |
| Authorization | GET /oauth/authorize on the connector host named by the issuer metadata (the DNS-only MCP host in production, so the approval popup never crosses a CDN challenge; the website host serves it too). A signed-in member sees the consent page. Parameters: response_type=code, client_id, redirect_uri, code_challenge and code_challenge_method=S256 (both required), state, scope and resource. Returned redirects carry iss (RFC 9207). A failure that cannot be sent to the assistant is shown on the page instead. |
| Token | POST https://mcp.firmledger.co.ke/oauth/token, form or JSON. grant_type=authorization_code (with code_verifier) or refresh_token. The response carries access_token (fl_oat_…), token_type: Bearer, expires_in: 28800, refresh_token (fl_ort_…) and scope. Never cached. |
| Revocation | POST https://mcp.firmledger.co.ke/oauth/revoke (RFC 7009) with token. Always answers 200. Revocation ends access on FirmLedger’s side only. The assistant keeps its stored token until the member removes the connector in the app. |
| Connection ends | Every connection of a member ends when the member changes or resets the password, resets two-factor, is suspended, or deletes the account. Signing out other devices does not end a connection. A suspended member is refused until reinstated, and a connection ended by suspension must be made again after reinstatement. |
| Scopes | This User MCP resource grants mcp (always), read:account (Pro; the member ticks it on the consent page) and write:account (Pro; off unless ticked, and every change is still confirmed). offline_access is advertised by the authorization-server metadata (SEP-2207) and accepted at sign-in and on refresh; it asks for a refresh token, which every connection gets anyway, and is never advertised by the protected-resource metadata. |
| Lifetimes | Authorization code: two minutes, single use. Access token: eight hours, signed, and every request re-checks the connection and the account, so revocation is immediate. Refresh token: rotated on every use, and each new one runs for 30 days from issue — a client that renews keeps one connection alive indefinitely, while one that does not renew must reconnect when its token runs out. Thirty days without a refresh ends it. |
| Maintenance | A planned window on the connector (or on the Developer API, which the connector sits behind) answers 503 with a JSON-RPC maintenance error and Retry-After. Whole-site maintenance keeps /mcp, discovery and /oauth/token answering, and closes the approval page at /oauth/authorize. The status page shows the MCP Server as Maintenance. Planned work is not counted as downtime. |
| Refresh reuse | A refresh token used again within 60 seconds is treated as a concurrent retry and renewed. Used again after that, the connection is ended (invalid_grant). |
| Code reuse | A second exchange of the same code ends the connection the first exchange made. |
| Checks on every request | HMAC-SHA256 signature, expiry, exact resource audience (public or User MCP), that the connection exists and is not revoked, that it was issued under the signing secret in force, and that the account is not suspended. |
| Challenges | An anonymous request_authentication on the public or User MCP answers 401 with a WWW-Authenticate pointer to that resource's metadata and required scope mcp. Optional User account scopes are presented separately on consent. For OpenAI clients (ChatGPT), the public connector's sign-in probe answers 200 with an isError tool result whose _meta["mcp/www_authenticate"] carries the challenge, because that is the shape those clients need to offer their sign-in UI. A rejected token answers 401 with error="invalid_token" and the same pointer, so the assistant renews or signs in again. A suspended account answers 403 with reason: account_suspended. |
| Tool metadata | Every tool in tools/list declares securitySchemes — the field a hosted client reads to decide when to offer its connect flow. Public directory and connection tools declare [{ "type": "noauth" }]; Pro tools declare [{ "type": "oauth2", scopes: ["mcp"] }]; account tools add read:account or write:account, so the scopes a client shows at its consent moment are the ones this server actually asks for. It is metadata to plan from, never the enforcement: gateFor still decides every call, and a key-carrying client keeps working exactly as before. offline_access never appears here (SEP-2207 keeps it to the authorization-server metadata). |
| Connection tools | get_connection_status reports public, free_authenticated, pro_authenticated or token_expiring_soon (inside the last 30 minutes of a token’s life, with a warning). request_authentication takes mode: connect (default), reauthenticate (ends this connection so the member can choose the account again) or refresh (renews a token inside its last 30 minutes). revoke_connection ends the connection on FirmLedger’s side. Sign-in connections cannot call manage_api_keys. |
| Not supported | Client ID metadata documents (CIMD) and confidential clients. Dynamic registration with PKCE is the supported route in. |
Token signing
Access tokens are signed with a secret that FirmLedger keeps on its own servers. Members and assistants never see it. Each connection records the signing key it was issued under, so if FirmLedger rotates that key, every connection ends at once and the assistant asks the member to connect again.
Access levels & scopes
There is one permission model with two front doors. For directory data, the MCP server uses the site’s own rule (canViewFull): full details are visible to Pro accounts, and to the owner of a listing for their own record. What a profile page shows everyone (the Sources panel, for example) the connector shows everyone too.
open | Anyone, including with no key: search_listings, get_listing, list_categories, list_countries, prepare_listing_submission, compare_listings, list_plans, list_ad_packages, prepare_upgrade, prepare_advertising, prepare_listing_claim, connection_status, get_connection_status, request_authentication, revoke_connection |
|---|---|
pro | Key + Pro: get_relationships, get_news, list_jobs, check_domain |
account_read | Key + Pro + scope read:account (“AI assistant — read my account”) |
account_write | Key + Pro + scope write:account (“AI assistant — act for me”) + a confirmation on every call |
| Gate order | Checked in this order: key → Pro → scope. The first failure returns key_required, pro_required (with upgrade_tool: "prepare_upgrade") or scope_required (with required_scope). |
| Opt-in scopes | read:account and write:account are not in a new key’s defaults. The member ticks them when creating the key, or later under the key’s Scopes. Changes apply on the next call. |
Pro-only fields in get_listing | website, email, phone, social_links, key_people, timeline, technology. They are omitted from the basic profile and named in access.locked_fields, exactly as the profile page hides them. |
| Never available | Other members’ data of any kind; payments and invoices; admin and moderation data; unapproved (pending or rejected) records in the directory tools, and jobs or relationships belonging to them. Account tools only ever see the key owner’s own listings, leads, notifications, watchlist and tickets. |
Confirmation handshake
Every account_write tool takes an optional confirmation_token and runs in two steps:
- Prepare. Call without a token. The server validates everything and writes nothing. It returns either
{"status":"no_change","message":…}(already in that state) or: { "status": "confirmation_required", "action": "reply_to_lead", "action_summary": "Send this reply to Wanjiku Buyer in conversation #12 (Boss Co): “Happy to demo on Tuesday.”", "details": { … }, "confirmation_token": "…", "expires_in_seconds": 600, "instructions": "Nothing has been done yet. Show action_summary to the user … Never confirm on their behalf." } - Ask. The assistant shows
action_summaryto the user and waits for a clear yes. - Confirm. Call again with the same arguments plus
confirmation_token. The server re-validates against the current state, consumes the token, runs the action through the same code path as the dashboard, and returns the result with"status": "done".
| Token binding | HMAC-signed and bound to the API key, the user, the tool name and a hash of the exact arguments. It is valid for 10 minutes and single-use: it is consumed even if the action then fails. |
|---|---|
| Failures | confirmation_invalid with reason: invalid (malformed or forged), expired, mismatch (other tool, key or user), args_changed (arguments differ from the confirmed ones) or used. Nothing is done in any of these cases. |
| Why tokens | A model cannot skip the prepare step, alter a message after the user approved it, or replay one approval for several actions. Clients’ own approval prompts (driven by the annotations) are a second, independent layer. |
Checkout links
prepare_upgrade and prepare_advertising return checkout_url: a signed link to /go/checkout/<token> that says who it was made for and what it buys. They charge nothing, create nothing and never see card data. No tool can confirm or complete a purchase.
GET the link | A review page (plan or package, price, listing, account). It changes nothing, so chat-app link previews are harmless. Not signed in → 302 to /login?next=…. Signed in as a different account, or a tampered token → 409, with nothing charged. |
|---|---|
POST (the member’s button) | Requires the session CSRF token, repeats every check against the signed-in account, records the payment intent exactly as the dashboard’s own buttons do (same FLPRO- / FLAD- references the webhooks match), then hands off to the existing Lemon Squeezy hosted checkout. |
| Lifetime | 24 hours. Also behind the payments maintenance switch. |
| No key | Returns status: "sign_in_needed" with the pricing or advertise page instead of a personal link. |
| Model guidance | Each result carries a note: if the user says “yes, do it”, give them the link again. Never claim a purchase is done. |
Result format
Each successful tool result has a one-line summary plus the JSON payload in content[0].text, and the same payload as structuredContent. Errors the assistant should explain (not found, permission, invalid input, confirmation problems) are tool results with isError: true and structuredContent.error = { code, message, … }, never protocol failures.
pro_required{ "isError": true, "structuredContent": { "error": { "code": "pro_required", "message": "Get news is a FirmLedger Pro feature, and the account behind this API key is not on Pro. …", "tier": "free", "upgrade_url": "…/pricing", "upgrade_tool": "prepare_upgrade", "setup_guide_url": "…/docs/mcp" } } }
Tools
This list is generated from the server’s live tool definitions, so it can’t drift from what the server offers. A listing argument accepts a slug (best), an exact name, a FirmLedger ID such as FL-00012, or a profile URL. Several exact-name matches return ambiguous with candidates. No match returns not_found, with any similarly named records labelled as different records.
Open tools open
No key or sign-in needed. With a key or a sign-in they respect the caller’s tier, e.g. get_listing adds the Pro fields for Pro.
get_listing Open · no key
Get a FirmLedger company profile. The public FirmLedger profile of one company: description, category, location, verification state (tick, how ownership was proved, when it was last checked), confidence and health scores, sources, recent moderated news headlines, open jobs count, reported revenue and the rolling ten-calendar-year revenue history answer (reported years, explicit gaps, per-year sources and the current/most recent figure with its sources). With FirmLedger Pro it also includes website, email, phone, social links, key people, timeline and technology — the same line the website draws.
listing * | string (≤ 300 chars) The company: its FirmLedger slug (best — returned by search_listings), its exact name, its FirmLedger ID such as "FL-00012", or its FirmLedger profile URL. |
|---|
list_countries Open · no key
List countries on FirmLedger. The countries present in the FirmLedger directory, each with its number of approved listings.
No arguments.
prepare_listing_submission Open · no key
Prepare a FirmLedger listing submission link. Does NOT create anything. Returns a link to the real FirmLedger submission form with the company name, website and (optional) category, country and city pre-filled. The user opens it, signs in, completes the remaining fields, reviews everything and submits it themselves. Also reports if the company is already listed, so nothing is submitted twice.
name * | string (≤ 120 chars) Company name. |
|---|---|
website | string (≤ 200 chars) Company website, e.g. "example.co.ke". |
category | string (≤ 60 chars) Optional category. |
country | string (≤ 60 chars) Optional country. |
city | string (≤ 80 chars) Optional city. |
compare_listings Open · no key
Compare FirmLedger listings side by side. Compare 2 to 4 approved FirmLedger companies side by side: entity type, category, location, founded, team size, website, tagline, date registered, ownership, tick, confidence and sponsored status — the same rows as the FirmLedger comparison page. Use after search_listings when the user wants to weigh companies against each other.
listings * | string[] (2–4 items) 2 to 4 companies (slugs are best). |
|---|
list_plans Open · no key
List FirmLedger Pro plans. The current FirmLedger Pro offers with price, billing and duration. With an API key it also says whether this account is already on Pro and whether a free trial is available. Use before prepare_upgrade.
No arguments.
prepare_upgrade Open · no key
Prepare a FirmLedger Pro checkout link. Does NOT charge anything. Returns a personal link to FirmLedger's own checkout for the chosen Pro plan (optionally with a promo code). The user opens it, signs in, reviews the order on FirmLedger and pays on the secure Lemon Squeezy checkout themselves. You can never complete or confirm a payment and must never ask for card details; if the user says "yes, do it", give them the link again. Without an API key it returns the pricing page instead.
plan | string (≤ 120 chars) The plan id from list_plans (or its exact name). |
|---|---|
promo_code | string (≤ 40 chars) Optional promo code the user gave you. |
prepare_listing_claim Open · no key
Prepare a listing ownership claim. Free. Prepare a link to claim an approved listing using DNS TXT, HTML meta tag or site badge. Creates nothing and never grants ownership. The user signs in, generates a token, installs the proof on the business domain and verifies it on FirmLedger.
listing * | string (≤ 300 chars) The company: its FirmLedger slug (best — returned by search_listings), its exact name, its FirmLedger ID such as "FL-00012", or its FirmLedger profile URL. |
|---|---|
method | "dns" | "meta" | "badge" |
connection_status Open · no key
Check FirmLedger connection access. Report what this FirmLedger connection can do: Free or Pro access, authentication and active free-trial status, and which tools and fields are available. Use when the user asks why something is locked.
No arguments.
get_connection_status Open · no key
Get FirmLedger connection status. Report how this assistant is connected to FirmLedger: public (no sign-in), an API key, or the member's account through sign-in (OAuth). Shows Free or Pro, the trial status, which tools and fields are available, and whether the sign-in token is about to expire. Use it when the member asks whether they are connected or signed in, or why something is locked.
No arguments.
request_authentication Open · no key
Connect or renew the FirmLedger account. Connect this assistant to the member's own FirmLedger account by sign-in (OAuth), or renew or replace that connection. Call it when the member says "connect me to my account", "sign me in", or asks to switch accounts. The assistant opens FirmLedger in the browser, the member approves there, and no password or key passes through the chat. mode "connect" (the default) starts sign-in when the connection needs it. mode "reauthenticate" ends the current connection so the member can choose the account again. mode "refresh" renews a sign-in token that is about to expire, and the assistant should then retry.
mode | "connect" | "reauthenticate" | "refresh" connect (default): sign in if needed. reauthenticate: end this connection so the member can choose the account again. refresh: renew a token that is about to expire. |
|---|
revoke_connection Open · no key
Remove the FirmLedger account connection. Disconnect this assistant from the member's FirmLedger account. The sign-in is revoked on FirmLedger's side at once, and every token it issued stops working. Call it when the member asks to remove, sign out or disconnect their account. It cannot erase the token stored in the member's assistant app, so tell the member to remove the FirmLedger connector there as well.
No arguments.
Pro directory tools pro
Need a connection (key or sign-in) whose account has Pro (paid or trial).
get_relationships PRO
Get a company's relationship graph (Pro). FirmLedger Pro. The recorded relationship graph of one company: founders, investors, parent companies, subsidiaries, products, services and partners — plus the reverse edges (companies it founded or invested in). Only relationships recorded by the verified owner or moderators; nothing is inferred.
listing * | string (≤ 300 chars) The company: its FirmLedger slug (best — returned by search_listings), its exact name, its FirmLedger ID such as "FL-00012", or its FirmLedger profile URL. |
|---|
get_news PRO
Get moderated company news (Pro). FirmLedger Pro. Recent moderated news headlines about one company with dates, publishers and links — the same accuracy-gated stories shown on the profile's news panel.
listing * | string (≤ 300 chars) The company: its FirmLedger slug (best — returned by search_listings), its exact name, its FirmLedger ID such as "FL-00012", or its FirmLedger profile URL. |
|---|---|
limit | any (default 10) Stories to return (1-12).. Integer or numeric string, clamped to 1–12. |
list_jobs PRO
List open jobs (Pro). FirmLedger Pro. Open roles at approved FirmLedger companies, filtered by company and/or location and keyword: title, company, location, posted date and where to apply. Jobs on unapproved listings never appear.
company | string (≤ 300 chars) Optional company (slug, exact name, FirmLedger ID or profile URL). |
|---|---|
location | string (≤ 80 chars) Optional location, e.g. "Nairobi" or "Kenya" or "Remote". |
query | string (≤ 80 chars) Optional keyword in the job title or description. |
limit | any (default 10) limit. Integer or numeric string, clamped to 1–25. |
offset | any (default 0) offset. Integer or numeric string, clamped to 0–10000. |
check_domain PRO
Check whether a website is listed (Pro). FirmLedger Pro. Given a website domain or URL, report whether an approved FirmLedger record uses it and return that record — useful for "is this site legit?" questions.
domain * | string (≤ 200 chars) A domain such as "example.co.ke" or a full URL. |
|---|
Account reads account_read
Need Pro + the read:account permission: a key with the AI-assistant scope, or a sign-in the member approved. Only ever return the member’s own data.
get_my_listings PRO read:account
List my FirmLedger listings. The listings this account owns or submitted, with moderation status, tick, scores, Pro perks, sponsorship, jobs and new-lead counts. Use first when the user refers to "my listing".
No arguments.
get_analytics PRO read:account
Read my audience analytics. Audience analytics for the user's own listings — views today/7/30 days/total, distinct hashed visitor signals, profile and website clicks, leads, the 30-day funnel (placements served and browser-measured viewable impressions are separate), top locations and per-listing views. The same numbers as Dashboard → Audience analytics.
listing | string (≤ 300 chars) Optional: one of the user's listings. Omit for all of them combined. |
|---|
list_leads PRO read:account
List my leads. The user's FirmLedger Leads inbox: inquiries received on their listings (box "received", the default), archived ones ("archived"), or inquiries they sent to other businesses ("sent"). Filter by status or listing; paginated.
box | "received" | "archived" | "sent" |
|---|---|
status | "new" | "contacted" | "qualified" | "won" | "lost" |
listing | string (≤ 300 chars) Optional: only leads for this listing of the user's. |
limit | integer (min 1 · max 50 · default 20) |
page | integer (min 1 · default 1) |
get_lead PRO read:account
Read one lead conversation. One conversation from the Leads inbox with the inquirer's details and every message, oldest first. Use before reply_to_lead so the reply fits the thread.
lead_id * | integer (min 1) lead_id from list_leads. |
|---|
get_watchlist PRO read:account
Read my watchlist. The companies the user watches on FirmLedger, newest first.
No arguments.
export_watchlist_csv PRO read:account
Export my watchlist as CSV. The user's watchlist as CSV text (name, category, type, location, contact details, tech stack, verified owner, confidence, watched since) — the same file as the dashboard's Export button.
No arguments.
get_notifications PRO read:account
Read my notifications. The user's in-app FirmLedger notifications, newest first, with the unread count. Set unread_only for just the unread ones.
unread_only | boolean (default false) |
|---|---|
limit | integer (min 1 · max 60 · default 20) |
list_my_tickets PRO read:account
Read my support tickets. The user's FirmLedger support tickets. Pass a ticket reference to read that ticket's full conversation.
ticket | string (≤ 40 chars) Optional ticket reference, e.g. "TK-1A2B3C". |
|---|
get_api_status PRO read:account
REST API v1 status for this key. The state of the FirmLedger REST API for this account and API key, as /api/v1/me and /api/v1/usage answer it: identity, plan and free-trial status, the key's scopes, live per-minute read/write budgets, durable usage aggregates and the endpoint catalogue. Also states plainly that the MCP connector and REST API v1 are the same platform API — one account, one key, one Pro entitlement — and exactly what happens to API and MCP access when a free trial ends.
key_prefix | string (≤ 40 chars) Optional: focus on one key by its prefix. |
|---|
list_webhooks PRO read:account
List my webhook destinations. The account's webhook destinations with events, category filters, active state and signing-secret prefix — plus recent deliveries (status, HTTP code, attempts, last error) when a webhook id is given.
webhook_id | integer (min 1) Optional: one destination, with its recent deliveries. |
|---|---|
limit | integer (min 1 · max 25 · default 10) |
list_api_keys PRO read:account
List my API keys. The account's API keys with label, prefix, scopes, usage totals and last use — never a raw key. Use it to name a key before revoking or re-scoping it.
No arguments.
get_my_revenue PRO read:account
View my revenue submissions. List published and pending revenue records for a listing you own, including moderation status and sources. Also returns the standard ten-year revenue history answer: reported years, gaps, per-year source URLs, and the figure/period/sources shown on the profile when no in-window history exists.
listing * | string (≤ 300 chars) The company: its FirmLedger slug (best — returned by search_listings), its exact name, its FirmLedger ID such as "FL-00012", or its FirmLedger profile URL. |
|---|
Account changes account_write
Need Pro + the write:account permission (key scope or approved sign-in), and the confirmation handshake on every call.
start_lead PRO write:account · confirms
Contact a claimed business. Start a new FirmLedger Leads conversation with another approved, claimed business. The signed-in member email is used automatically, the business email stays private, and the owner is notified in their Leads inbox. Requires the user’s confirmation of the exact inquiry.
listing * | string (≤ 300 chars) The company: its FirmLedger slug (best — returned by search_listings), its exact name, its FirmLedger ID such as "FL-00012", or its FirmLedger profile URL. |
|---|---|
name | string (≤ 120 chars) Optional sender name; defaults to the member profile name. |
phone | string (≤ 40 chars) |
looking_for | string (≤ 140 chars) Optional short subject or what the member needs. |
message * | string (≤ 4000 chars) |
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
reply_to_lead PRO write:account · confirms
Reply to a lead. Send a reply in one of the user's lead conversations, exactly as the Reply box on the website does (the other party is notified). Requires the user's confirmation: the first call returns the exact message for them to approve.
lead_id * | integer (min 1) |
|---|---|
message * | string (≤ 4000 chars) The reply text, written or approved by the user. |
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
set_lead_status PRO write:account · confirms
Change a lead's status. Move a lead in the user's business inbox to new, contacted, qualified, won or lost. Requires the user's confirmation.
lead_id * | integer (min 1) |
|---|---|
status * | "new" | "contacted" | "qualified" | "won" | "lost" |
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
watchlist_add PRO write:account · confirms
Add a company to my watchlist. Add an approved company to the user's watchlist so they are notified when its record changes. Requires the user's confirmation.
listing * | string (≤ 300 chars) The company: its FirmLedger slug (best — returned by search_listings), its exact name, its FirmLedger ID such as "FL-00012", or its FirmLedger profile URL. |
|---|---|
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
watchlist_remove PRO write:account · confirms
Remove a company from my watchlist. Remove a company from the user's watchlist. Requires the user's confirmation.
listing * | string (≤ 300 chars) The company: its FirmLedger slug (best — returned by search_listings), its exact name, its FirmLedger ID such as "FL-00012", or its FirmLedger profile URL. |
|---|---|
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
create_support_ticket PRO write:account · confirms
Open a support ticket. Open a FirmLedger support ticket for the user. Categories: billing, technical, listing, account, verification, other. Requires the user's confirmation of the exact subject and message.
subject * | string (≤ 200 chars) |
|---|---|
category * | "billing" | "technical" | "listing" | "account" | "verification" | "other" |
body * | string (≤ 5000 chars) The issue in a few sentences. |
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
manage_job PRO write:account · confirms
Edit, close or remove my job. Manage a job owned by the member: edit its title, type, location, apply link or description, close it, or permanently remove it. Editing a live or closed role returns it to moderation before it is public again. Requires the user’s confirmation.
action * | "edit" | "close" | "remove" | "delete" |
|---|---|
job_id * | integer (min 1) |
title | string (≤ 80 chars) |
role_type | "Full-time" | "Part-time" | "Contract" | "Internship" | "Remote" |
location | string (≤ 80 chars) |
apply_url | string (≤ 400 chars) |
description | string (≤ 600 chars) |
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
post_job PRO write:account · confirms
Post a job on my listing. Submit a job opening on one of the user's listings (the listing needs active Pro perks; up to 5 open or pending roles). Like the dashboard form, it goes to moderation first and is published after approval. Requires the user's confirmation.
listing * | string (≤ 300 chars) One of the user's own listings. |
|---|---|
title * | string (≤ 80 chars) |
role_type | "Full-time" | "Part-time" | "Contract" | "Internship" | "Remote" |
location * | string (≤ 80 chars) City, or "Remote". |
apply_url * | string (≤ 400 chars) Full https:// link to the job post or careers page. |
description * | string (≤ 600 chars) |
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
mark_notifications_read PRO write:account · confirms
Mark notifications as read. Mark one notification (notification_id) or all of them (all: true) as read. Requires the user's confirmation.
notification_id | integer (min 1) |
|---|---|
all | boolean |
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
refresh_listing_scores PRO write:account · confirms
Refresh my listing's scores. Recompute the confidence, health and completeness scores of one of the user's listings from its current record — the dashboard's "Re-align scores". Requires the user's confirmation.
listing * | string (≤ 300 chars) One of the user's own listings. |
|---|---|
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
refresh_listing_tech PRO write:account · confirms
Refresh my listing's technology snapshot. Scan the website of one of the user's listings and refresh its technology snapshot — the dashboard's "Refresh tech". Requires the user's confirmation.
listing * | string (≤ 300 chars) One of the user's own listings. |
|---|---|
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
refresh_listing_news PRO write:account · confirms
Refresh my listing's news. Look for new coverage of one of the user's approved listings with the same accuracy rules and moderation as the dashboard's "Refresh news" (once a minute per listing). Requires the user's confirmation.
listing * | string (≤ 300 chars) One of the user's own approved listings. |
|---|---|
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
request_listing_removal PRO write:account · confirms
Request removal of my listing. Send a removal request for a listing the user owns or originally submitted. A moderator reviews it; nothing is removed until they accept. Requires the user's confirmation.
listing * | string (≤ 300 chars) A listing the user owns or submitted. |
|---|---|
reason * | string (≤ 2000 chars) Why it should be removed (20+ characters). |
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
create_webhook PRO write:account · confirms
Create a webhook destination. Register a webhook on the user’s account after explicit confirmation. Listing events will be sent to this URL. Requires Pro (including a free trial) and write:account. The signing secret is returned only once; keep it private.
url * | string (≤ 500 chars) Public HTTPS destination for signed events. |
|---|---|
label | string (≤ 80 chars) |
events * | string[] (1–6 items) |
categories | string[] |
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
reply_to_ticket PRO write:account · confirms
Reply to an open support ticket. Send a reply in one of the user's own support tickets — the same thread the dashboard shows, and the same message an agent answers. Closed tickets cannot take replies. Requires the user's confirmation of the exact text.
ticket * | string (≤ 40 chars) The ticket reference (e.g. TK-1A2B3C) or its id, from list_my_tickets. |
|---|---|
message * | string (≤ 4000 chars) The reply text, written or approved by the user. |
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
manage_webhooks PRO write:account · confirms
Manage my webhook destinations. Create, update, pause, resume, rotate the signing secret of, test, retry a delivery for, or delete a webhook destination on the user's account — the same operations as Dashboard → Developer API. Secrets come back only for create and rotate. Requires the user's confirmation.
action * | "create" | "update" | "pause" | "resume" | "rotate_secret" | "test" | "retry_delivery" | "delete" |
|---|---|
webhook_id | integer (min 1) Required for every action except create. |
url | string (≤ 500 chars) create/update: the receiver URL (https in production). |
label | string (≤ 80 chars) |
events | string[] create/update: event types to subscribe to. |
categories | string[] |
delivery_id | integer (min 1) retry_delivery: which delivery to retry. |
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
manage_api_keys PRO write:account · confirms
Manage my API keys. Create an API key (the raw key is returned once), revoke a key, or change a key's scopes — the dashboard's Developer API controls. Requires the user's confirmation. Keys can also carry the AI-assistant scopes (read:account / write:account) that this connector uses.
action * | "create" | "revoke" | "update_scopes" |
|---|---|
label | string (≤ 60 chars) create: a name for the key. |
scopes | string[] (1–9 items) Scope ids. Required for update_scopes; create defaults to the standard scopes. |
key_id | integer (min 1) revoke/update_scopes: the key id from list_api_keys. |
key_prefix | string (≤ 40 chars) Optional alternative to key_id. |
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
manage_my_revenue PRO write:account · confirms
Submit a revenue change for moderation. Add reported revenue, edit an approved owner record with target_id, or request deletion. All changes require explicit confirmation and moderation; never immediately changes published revenue. Sources are public HTTPS URLs separated by newlines.
listing * | string (≤ 300 chars) The company: its FirmLedger slug (best — returned by search_listings), its exact name, its FirmLedger ID such as "FL-00012", or its FirmLedger profile URL. |
|---|---|
action * | "save" | "delete" |
target_id | integer (min 1) |
amount | string (≤ 32 chars) |
currency | string (≤ 3 chars) |
period | string (≤ 10 chars) |
sources | string (≤ 5000 chars) |
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
update_my_listing PRO write:account · confirms
Edit one of my listings. Change fields on a listing the user owns (name, tagline, description, website, email, phone, country, city, region, address, logo_url, founded, size, tags, socials) — the same service the dashboard edit form and PUT /api/v1/my/listings/:id use, including the re-moderation rule when the core record changes. Requires the user's confirmation.
listing * | string (≤ 300 chars) The company: its FirmLedger slug (best — returned by search_listings), its exact name, its FirmLedger ID such as "FL-00012", or its FirmLedger profile URL. |
|---|---|
name | string (≤ 120 chars) |
tagline | string (≤ 160 chars) |
description | string (≤ 4000 chars) |
website | string (≤ 300 chars) |
email | string (≤ 190 chars) |
phone | string (≤ 40 chars) |
country | string (≤ 80 chars) |
city | string (≤ 80 chars) |
region | string (≤ 80 chars) |
address | string (≤ 200 chars) |
logo_url | string (≤ 500 chars) |
founded | string (≤ 12 chars) |
size | string (≤ 40 chars) |
tags | string (≤ 300 chars) |
socials | any Social profile links keyed by network; use an empty object to clear. A JSON object string is also accepted. |
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
manage_lead PRO write:account · confirms
Archive, restore or delete a lead conversation. Archive a conversation out of the business inbox, restore it, or delete it permanently — the dashboard's conversation controls. Requires the user's confirmation (deleting is irreversible).
action * | "archive" | "unarchive" | "delete" |
|---|---|
lead_id * | integer (min 1) The lead id from list_leads. |
confirmation_token | string (≤ 2048 chars) Leave empty on the first call: you get an action_summary and a token instead of the action. After the user explicitly confirms that summary, call again with the SAME arguments plus this token. |
Errors
Tool errors (isError: true, HTTP 200)
not_found | No approved record (or none of yours) matches. Includes did_you_mean (different records, clearly labelled) and, for companies, a hint to use prepare_listing_submission. Never answered with invented data. |
|---|---|
ambiguous | Several records share that exact name. Includes candidates, so call again with a slug. |
key_required | An account tool was called with no API key. Includes create_key_url. |
pro_required | The tool or action needs Pro. Includes tier, upgrade_url, upgrade_tool: "prepare_upgrade" and, when eligible, free_trial_url. |
scope_required | The key lacks read:account / write:account. Includes required_scope and manage_keys_url. |
confirmation_invalid | See the confirmation handshake. reason is one of invalid, expired, mismatch, args_changed or used. |
not_approved | The action needs an approved listing (e.g. advertising, posting a job) and this one is pending or rejected. |
limit_reached | A site rule was hit, e.g. at most 5 open positions per listing. |
payments_unavailable · payments_paused · checkout_unavailable | Online checkout isn’t configured, is in maintenance, or the plan or package isn’t connected to checkout. No link was prepared. |
promo_invalid | The promo code passed to prepare_upgrade can’t be applied. No link was prepared. |
upstream_unavailable | A confirmed refresh (news or tech) couldn’t reach its source. Safe to try later. |
invalid_input | A required argument is missing or malformed. The message, and errors where present, say which. |
unknown_tool | No tool by that name. Includes available_tools. |
dashboard_only | manage_api_keys is refused for sign-in connections: keys are created and changed only on the dashboard. Includes dashboard_url. |
sign_in_required (HTTP 401) | An anonymous request_authentication. The HTTP response carries WWW-Authenticate with resource_metadata, so an OAuth-capable assistant starts sign-in itself. |
invalid_token (HTTP 401) | A sign-in token that is tampered with, expired, for another resource, or whose connection was ended. Includes resource_metadata. Renew with the refresh token, or sign in again. An app that keeps reporting an expired connection after a fresh sign-in is holding its own stored sign-in: remove the connector in that app and add it again. |
internal_error | An unexpected failure (logged server-side). Nothing further was changed. Safe to retry. |
HTTP errors (JSON-RPC error body)
401 | Unknown, malformed or revoked key (reason: invalid_key / key_revoked). At /mcp/sign-in, a member API key answers reason: wrong_endpoint with the key-based addresses named, and a moderator or admin key answers wrong_surface. JSON-RPC -32001. |
|---|---|
403 | account_suspended (-32004) or insufficient_scope for read:listings (-32005) |
405 | DELETE is not supported on this stateless endpoint. Use POST for JSON-RPC and GET for the Streamable HTTP event stream. |
429 | Rate limit or confirmed-write limit (-32006), or the per-address lock after repeated bad keys (-32003). Includes Retry-After. |
503 | The API area is in maintenance (-32002). Includes Retry-After. |
400 / 404 | Body isn’t JSON-RPC (-32700), or a path other than /mcp or /mcp/<key> (-32601) |
Limits
| With a key | 60 tool calls per minute per key: the same live per-key read budget as the REST API, in a separate bucket. initialize and tools/list are free. |
|---|---|
| Confirmed changes | An account-change call with a confirmation_token is also charged to the key’s write budget: 20 per minute, the same as REST writes. Prepare calls (no token) are ordinary calls. |
| Without a key | 120 tool calls per minute per client address |
| Page sizes | search_listings ≤ 25 (default 10) · list_jobs ≤ 25 (default 10) · get_news ≤ 12 · offsets ≤ 10000. Out-of-range values are clamped, and the response reports the limit actually used. |
| Paging | List responses carry total (exact), count, offset, has_more and next_offset. |
| Usage counters | MCP calls are not added to your REST usage statistics. |
Connection and action semantics
connection_status returns tier (free/pro), authenticated, access_source (free/free_trial/paid_pro), trial_active, trial_expires_at and access_expires_at. A keyless connection cannot identify a member’s trial. Account scopes remain required during trials.
prepare_listing_claim writes nothing, accepts dns/meta/badge and links to the ownership verification form. create_webhook uses the shared webhook service’s public-URL validation, signing, delivery queue and limits. Creating a destination requires explicit confirmation and returns a signing secret once; it does not send a test event. Removal requests remain subject to ownership and moderation checks.
FirmLedger has no Vercel OAuth flow. A Vercel login challenge indicates connector configuration or an upstream deployment restriction to investigate; do not add OAuth discovery metadata or disable invalid-key checks to hide it. Production MCP must be reachable without a hosting-provider login.
Safety guarantees
- Directory tools are read-only at the database level. They query through a separate SQLite connection opened
readonly. A write through it fails withSQLITE_READONLY. - Account reads write nothing, not even “mark as read”. Marking notifications read is its own confirmed action.
- Account changes reuse the dashboard’s code (the same validation, limits, notifications and moderation), so the connector can’t do anything the member couldn’t do on the website. It can’t edit, delete or claim listings, and removal is a request that a moderator reviews.
- Checkout links create nothing until the member presses the button on FirmLedger itself, signed in, with a CSRF token.
prepare_listing_submissiononly builds a link to/dashboard/listings/newwith the fields pre-filled.- The test suite (
npm run test:mcp) fingerprints every relevant table before and after exercising every read tool and every prepare step, and fails on any difference. It then checks that each confirmed action changes exactly what it described.
Raw examples
searchcurl -s https://mcp.firmledger.co.ke/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer fl_live_your_key" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_listings","arguments":{"category":"Fintech","city":"Nairobi","verified_only":true,"limit":5}}}'
prepare, then confirm# 1. prepare: returns action_summary + confirmation_token, changes nothing {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"watchlist_add","arguments":{"listing":"kilimo-soft"}}} # 2. after the user says yes: same arguments + the token {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"watchlist_add","arguments":{"listing":"kilimo-soft","confirmation_token":"…"}}}
SDK clients do the initialize handshake for you. The stateless server also answers a bare tools/call, as above.