Technical Details
The Pandects MCP is a remote MCP server at https://api.pandects.org/mcp. All corpus tools are read-only; the single write surface is submit_feedback, which stores agent experience feedback for the maintainer.
It is designed for human users working through LLM clients, but this page describes the technical surface available behind the scenes.
High-Level Capability Areas
The current MCP surface covers:
- Agreement discovery and retrieval
- Section search and section listing within one agreement
- Section-level retrieval
- Concept-to-taxonomy mapping and focused snippet extraction
- Tax clause retrieval
- Filter and taxonomy lookup
- Counsel, NAICS, summary, trend, and server-introspection reference data
- An agent feedback channel (
submit_feedback)
Tool Groups
Agreement Discovery And Retrieval
search_agreementslist_agreementsget_agreementget_agreements_batch
Use these when the client needs to find the right agreement first or fetch one or more agreements directly. get_agreements_batch fetches metadata for up to 25 known agreement UUIDs in one call, closing the N× get_agreement gap for a known set.
Section Research
search_sectionslist_agreement_sectionslist_agreement_sections_batchget_sectionget_section_snippetget_section_snippets_batchget_sections_batch
Use these when the client needs to search clause language across the corpus, navigate sections inside one agreement, inspect a known section directly, or extract a shorter plain-text excerpt from one section. list_agreement_sections_batch, get_section_snippets_batch, and get_sections_batch accept a list of agreement or section UUIDs and return results in a single call, reducing round-trips for multi-agreement workflows. get_sections_batch returns full section XML (capped by default at 10 000 characters per section); get_section_snippets_batch returns focused plain-text excerpts and is the right choice when the full XML is not needed.
Discovery Helpers
suggest_clause_families
Use this when the client knows the business or legal concept but does not know the right taxonomy standard_id yet. The tool returns ranked clause-family candidates with their taxonomy paths and matched terms.
Each match also reports whether it is a canonical fit, a proxy, or a broader semantic match.
The response additionally rolls the per-match verdicts up into a top-level coverage (canonical / proxy / weak / none) and a human-readable coverage_note. When coverage is weak or none, the concept is not cleanly represented and the returned nodes may be adjacent — or even opposite — concepts (for example, "go-shop" only surfaces the distinct "no-shop" node), so clients should verify them in section text rather than filtering on them blindly. Pass taxonomy=tax_clauses to map tax concepts and receive dotted tax.* ids for search_tax_clauses.
Tax Clause Research
search_tax_clausesget_agreement_tax_clausesget_section_tax_clauses
Use these when the task is specifically about extracted tax-module clauses rather than the full agreement or section text. search_tax_clauses is the corpus-wide entry point for the tax taxonomy: it mirrors search_sections but filters clause-level tax precedents by tax_standard_id (dotted tax.* ids from suggest_clause_families(taxonomy='tax_clauses') or get_tax_clause_taxonomy) plus the standard M&A filters. It excludes clauses inside representations & warranties by default; pass include_rep_warranty=true to include them. get_agreement_tax_clauses and get_section_tax_clauses remain the right tools once you already hold a specific agreement or section UUID.
Research Bootstrap
list_filter_optionsget_clause_taxonomyget_tax_clause_taxonomy
Use these when the client needs valid structured inputs before searching.
Reference And Context
get_counsel_catalogget_naics_catalogget_agreements_summaryget_agreement_trendsget_server_capabilitiesget_server_metrics
Use these when the client needs canonical lookup data, corpus-level context, or MCP introspection metadata.
Feedback
submit_feedback
The one write tool on the server. Agents (or users, through their client) use it to report bugs, workflow friction, missing capabilities, data-quality issues, documentation gaps, and praise for what worked well, during or at the end of a research session. Submissions are persisted with the submitting account, OAuth client, and scope set, and are read by the maintainer; there is no read-back surface.
Inputs: required summary (≤ 200 chars) and detail (≤ 5000 chars); optional category (bug, friction, missing_capability, data_quality, docs_gap, praise, other — default other), severity (low/medium/high), tool_name (validated against the live tool registry), suggestions (≤ 2000 chars), and context (a small JSON object, ≤ 2000 chars serialized — typically the arguments that triggered the issue). Whitespace-only text, an unknown tool_name, or an oversized context is rejected with an agent-visible -32602 explaining the fix. Submissions are limited to 20 per account per rolling hour; hitting the limit returns an agent-visible message asking for consolidated reports. The response acknowledges with a feedback_id and recorded_at.
Current Tool List
The current MCP tools are:
search_agreementssearch_sectionssearch_tax_clauseslist_agreementslist_agreement_sectionslist_agreement_sections_batchget_agreementget_agreements_batchget_sectionget_section_snippetget_section_snippets_batchget_sections_batchget_agreement_tax_clausesget_section_tax_clauseslist_filter_optionssuggest_clause_familiesget_server_metricsget_server_capabilitiesget_clause_taxonomyget_tax_clause_taxonomyget_counsel_catalogget_naics_catalogget_agreements_summaryget_agreement_trendssubmit_feedback
Design Notes
- Every corpus tool is read-only;
submit_feedbackis the only tool that writes, and it writes exclusively to the feedback store - Clients should typically choose the right tools automatically
search_agreementsis the discovery-oriented agreement search; accepts astandard_idlist to filter to agreements that contain at least one section tagged with any of the given taxonomy idslist_agreementsis the exact-filter, cursor-based agreement listing surface; also acceptsstandard_idfor taxonomy-based agreement filtering, and reports dropped ids underinterpretation.unrecognized_standard_ids. Unlikesearch_agreements, which always emits a four-keyinterpretationblock,list_agreementsemitsinterpretationonly when ids were dropped, and it carries exactlyunrecognized_standard_idsandnotes(additionalProperties: false) — so read it defensively and do not expecttaxonomy_filterstherelist_agreementsrows are rich (full deal metadata);search_agreementsrows are slim (agreement_uuid,year,target,acquirer,filing_date,url,verified,section_count,target_counsel,acquirer_counsel). Uselist_agreementswhen the caller needs full metadata and expects to paginate deeply or export; usesearch_agreementsfor exploratory discovery. A fulllist_agreementsrow atpage_size=100produces roughly 100k characters — passfields(see below) or keeppage_size <= 25when reading results in-context- Both
search_agreementsandlist_agreementsaccept an optionalfields: [string]array (validated against that tool's own row keys) that projects each result row down to only the requested keys;agreement_uuidis always included regardless. Omittingfieldskeeps the full row for back-compat.agreement_uuidis the only key both tools' output schemas require —search_agreements'verifiedfield was previously required on every row, but a projected row can omit it, so the schema now only guaranteesagreement_uuid. Example:fields: ["agreement_uuid", "url"]for a bulk-export scan that only needs identifiers and EDGAR links - Both
search_agreementsandlist_agreementsrows includetarget_counsel/acquirer_counsel(canonical firm names, ordered by position, one array per side), fetched with one batched query per page joiningagreement_counsel/counsel. This closes a gap where filtering byany_counselmatched a firm on either side but gave no way to tell which side matched. The batched query is skipped whenfieldsis provided and does not request either counsel key list_agreementsacceptssort_by(agreement_uuid— default, the original behavior —year,target,acquirer,filing_date) andsort_dir(asc/desc). Thecursoris a versioned, opaque composite keyset token that also embeds thesort_by/sort_dirit was issued under; a cursor from a call with different sort settings, or a cursor from before sort parity shipped, is rejected with an agent-visible-32602error rather than being silently misapplied or crashing — restart pagination without a cursor when changing sort settings. NULLs in the sort column always sort last, regardless ofsort_dir, since MariaDB has noNULLS LASTkeyword; ties (including the all-null block) break byagreement_uuidascending so a full paginated scan stays gap- and duplicate-freelist_agreement_sectionsis an MCP convenience tool for within-agreement navigationsearch_sectionsis a clause-language retrieval surface, not a normalized document-facts surface; it has no free-text/keyword parameter — it searches by clause-familystandard_idand the structured M&A filters only. Usesuggest_clause_families(orget_clause_taxonomy) to translate a plain-English concept into astandard_id, then filter by it. There is no keyword-search fallback to add: thesection_text_searchtable in the main DB holds plain text for 10 477 of 967 121 sections (1.1%), roughly half of which are governing-law clauses, and it carries no FULLTEXT index — only B-tree keys onsection_uuid,agreement_uuid, andxml_version. Exposing it would answer phrase queries from a narrow, unrepresentative slice of the corpus and return confident false negatives for everything else. If a concept has no taxonomy node, that should be reported as a coverage gap rather than approximatedsearch_sectionsexposescount_modeand returnscount_metadataplusinterpretationso clients can tell when totals are exact versus estimated and when taxonomy is acting as a proxysearch_agreementsreturns exact totals today and also includescount_metadataplusinterpretationstandard_idvalues are opaque 16-character hex node IDs obtained fromget_clause_taxonomyorsuggest_clause_families— they are not dotted decimal numbers. Unrecognized IDs are ignored (they silently match nothing) and are echoed back underinterpretation.unrecognized_standard_ids— alongsideinterpretation.taxonomy_filtersonsearch_sections/search_agreements, which emit the full interpretation block;list_agreementsreports only the dropped ids andnotes. This lets clients detect a mistyped or stale ID rather than mistaking an empty result for "no matches"suggest_clause_familiesexists to bridge plain-English concepts to taxonomy ids and now reports fit/confidence metadata so clients can distinguish canonical matches from broader proxies, plus a roll-upcoverage/coverage_noteso a weak or unrepresented concept is not silently treated as covered. Node labels are not unique: 69 of the 172 taxonomy nodes share a label with another node (the reps taxonomy carries one copy under Buyer/Parent and another under Company/Seller), and the duplicates score identically, solabelalone cannot break the tie. Every match therefore carriesqualified_label— the label plus the distinguishing ancestor in brackets when it collides, unchanged otherwise — and alabel_is_ambiguousflag.qualified_labelis unique across the taxonomy: where no ancestor separates the nodes (one branch repeats the same label at L1, L2 and L3) it falls back to thestandard_id. Select nodes bystandard_id; usequalified_labelwhen naming one- The tax taxonomy is a separate namespace from the clause taxonomy: tax nodes are dotted
tax.*ids and are searched corpus-wide throughsearch_tax_clausesviatax_standard_id(not the 16-hexstandard_idused bysearch_sections/search_agreements). Passing atax.*id tostandard_idis ignored and echoed underinterpretation.unrecognized_standard_ids; the reverse — a 16-hex clause-family id passed totax_standard_id— is echoed underinterpretation.unrecognized_tax_standard_idsonsearch_tax_clauses,get_agreement_tax_clauses, andget_section_tax_clauses, with an extra note when the ignored id has the 16-hex clause-family shape. The block is present only when ids were dropped, so its absence means every id was applied. Usesuggest_clause_families(taxonomy='tax_clauses')to obtain valid tax ids get_section_snippetis a focused reading aid, not a replacement forget_sectionor a canonical extracted-facts surfaceget_section_snippets_batchandlist_agreement_sections_batchaccept arrays of UUIDs and collapse multiple single-item calls into one round-trip; use them when a workflow would otherwise fan out across many agreements or sectionsget_sections_batchfetches full section XML for up to 10 sections in one call; XML is capped atmax_xml_charsper section (default 10 000, range 500–20 000) to prevent context overload; when a section is truncated the result includesxml_truncated: true; passmax_xml_chars: nullonly if uncapped XML is explicitly needed- The batch retrieval tools distinguish a missing record from an empty one, so a partial result cannot be read as a measured absence.
get_sections_batchomits asection_uuidthat matches no section and names it underunresolved_section_uuids, soreturned_countbeing lower than the number of distinct UUIDs passed is explained in-band rather than left for the caller to detect by diffing.list_agreement_sections_batchdoes the same for agreement UUIDs underunresolved_agreement_uuids: an unknown or non-retrievable agreement is dropped fromresultsinstead of being returned as asection_count: 0entry, which previously asserted that a real agreement had no sections.get_section_snippets_batchreports the same field. The ids appear in two places on each response — top level and insideinterpretation— so either read works. As with the taxonomyunrecognized_*blocks, the field is present only when something was dropped, so its absence means every UUID resolved. Repeated UUIDs are deduplicated before the lookup, so a uuid passed twice yields one result and is counted once list_agreement_sections_batchcaps each agreement atmax_sections_per_agreementsections (default 200, range 1–1000). Agreements run to 530 sections, so 20 of them uncapped is roughly 6 400 rows in a single response — the one batch tool that bounded only its input count and not its payload. Each entry carries three distinct counts:total_agreement_sections(every section in the agreement, ignoring anystandard_idfilter),matched_section_count(sections matching the filter, before the cap), andsection_count(what the response actually contains, after the cap) — plussections_truncated; page through a single long agreement withlist_agreement_sectionsinstead of raising the caplist_agreement_sectionsreturns not-found for an agreement UUID with no sections in the search index, matchingget_agreementfor the same UUID. The index currently holds exactly the retrievable agreements and every one of them has at least one section, so an empty unfiltered listing can only mean a bad UUID; returning an empty page for it claimed an absence that had never been measured. An emptyresultspage now unambiguously means thestandard_idfilter matched nothing. Note the section tools test membership of that index rather than re-applying the eligibility gate directly — the two coincide on the current corpus, but they are maintained by separate ETL stepssearch_sectionsresults includefiling_dateandtransaction_price_totalinline on every result without needing to request them viametadata. Requestingtransaction_price_totalinmetadatano longer repeats it inside the metadata block on the MCP contract, since the value is already top-level; the web contract is unchanged. If it was the only field requested, the now-emptymetadataobject is dropped from the result entirely rather than returned empty, so index it defensivelysearch_sectionsacceptsinclude_snippet: true(with optionalsnippet_focus_termsandsnippet_max_chars, default 400, range 120–1200) to return a plain-textsnippetplusmatched_terms,source_length, andmonetary_valueson every result. This collapses the usualsearch_sections→get_section_snippets_batchpair into one call for the common "find the clause and read what it says" workflow; the section text is already loaded to serve the search, so the excerpt costs no extra query. It stays off by default, and is far smaller thaninclude_xmlsearch_sectionsreportspage_unique_agreement_count— distinct agreements within the returned page, not across the whole filtered set. It sits next to a corpus-widetotal_count, so the scope is named explicitly to prevent reading "241 sections across 3 agreements" off a 3-result pageget_section_snippet,get_section_snippets_batch,get_sections_batch, andsearch_sectionswithinclude_snippet: trueall include amonetary_valueslist — dollar amounts and value expressions extracted from the section text — so clients can surface deal economics without parsing XML. Extraction requires the digit run to end in a digit and unit suffixes (B/M/T/K,bn,mm,million, …) to end on a word boundary, so trailing punctuation and the first letter of the following word are not absorbed into the amountsearch_sections,search_agreements, andlist_agreementsall acceptfiled_afterandfiled_before(ISO 8601 date strings,YYYY-MM-DD) for sub-year filing-date precision;year/year_min/year_maxfilter on the agreement year;filed_after/filed_beforefilter on the exact filing date.list_agreementspreviously advertised and validated these five fields (any_counsel,year_min,year_max,filed_after,filed_before) without ever applyingfiled_after/filed_beforein the query — the filter was silently a no-op. Both the bug and the schema/handler drift that let it through the advertised-schema build are fixed:list_agreements' advertised input schema is now built from the exact schema instance its handler validates against, instead of a narrower base schema patched with JSON-schema-only overrides that a field not already present would silently dropget_agreementreturns metadata only by default (the response reportsxml_included: false) to keep the payload small; passinclude_xml: trueto also return the agreement XML, which preserves the existing redaction and full-text access behavior (focus_section_uuid/neighbor_sectionsonly take effect wheninclude_xmlis true and the response is redacted). A caller holdingagreements:read_fulltexttherefore gets the whole document and those two arguments do nothing — and agreement XML is large: 91.1% of retrievable agreements exceed 200 000 characters (9 021 of 9 902), the mean is ~345 000 and the largest is 2 400 873, soinclude_xml: truewill exhaust a typical context window. To read specific clauses from a known agreement, usesearch_sectionswithagreement_uuidandinclude_snippet: true, orget_sections_batch(which caps XML per section), rather than fetching the full bodyget_agreements_batchfetches metadata for up to 25 known agreement UUIDs in one call, using the same column list asget_agreementplustarget_industry_label/acquirer_industry_labeland thetarget_counsel/acquirer_counselecho. Metadata only — there is noinclude_xmloption. A UUID that matches no retrievable agreement is omitted fromresultsand named underunresolved_agreement_uuids, following the same pattern asget_sections_batch/list_agreement_sections_batch: do not read a shorterresultslist as an absence without checking that fieldtarget_industry_label/acquirer_industry_label(onlist_agreements,get_agreement,get_agreements_batch, andsearch_sectionsper-resultmetadata) are now always present when the row itself is present,nullrather than omitted when the underlying NAICS code is absent or does not resolve in the catalog. A real export previously found the key missing on 9 of 326 rows, which broke strict downstream schema validation expecting the key always presentverified(onsearch_agreementsrows) is a metadata human-verification flag — true when the agreement's deal metadata has been reviewed and confirmed by hand — distinct from the automated XML-structure verifier that runs during ingestion (thexml.statuscolumn). It also gates visibility for otherwise-gated filings: a gated agreement only appears in results whenverifiedis true, though an ungated agreement can appear with either valueget_agreements_summaryreportsagreementsandsectionsat the scope the retrieval tools can actually return, andpagesat ingestion scope.agreementsis counted over the same expressions every listing builds (the join to the latest verified XML row, plus the public-eligibility gate), so it equals an unfilteredsearch_agreementstotal_count.sectionscounts the same search indexsearch_sectionspaginates, so it equals an unfilteredsearch_sectionstotal.pagesis the ingestion-wide total from thesummary_datarollup and is not retrievable-scoped: the per-page table that would let it be filtered to the retrievable set holds full page content, is ETL-only, and is not present in the serving schema, so there is no served source for a retrievable page count. Treatpagesas a corpus-ingestion size, the same figure the website shows; do not divide it byagreementsfor a per-retrievable-document average, since the two are different scopes.agreementsandsectionsare read live rather than from the rollup because that rollup omits the latest-verified-XML join and so counts agreements no tool here can return — on the current corpus 10 784 rollup agreements against 9 902 retrievable, an 8.2% gap- The website and the MCP server publish deliberately different corpus counts, and this is not a bug. The site's summary cards report the ingestion rollup (
summary_data, 10 784 agreements);get_agreements_summaryreports the retrievable universe (9 902). The 882-agreement difference is agreements that have been ingested but have no latest verified XML, so no Pandects surface — web index, REST API, or MCP — can return their text.sectionsandpagesfigures shown on the site come from the same rollup and are scoped to it. When quoting a corpus size, say which measure you mean list_filter_optionsreturns more than the value lists:retrieval_parameter_maptranslates each plural catalog key into the singular argument name the retrieval tools expect (target_industries→target_industry), andindustry_labelsdecodes the NAICS codes in the industry lists. A handful of in-use codes (for example37,67,459,513,677,922) have no entry in the NAICS reference tables and so appear in the filter lists without a label- Industry filters match on NAICS codes, not descriptions.
target_industry: ["511"]works;target_industry: ["Technology"]matches nothing and is not reported as an unrecognized value, so it looks like a real empty result. Uselist_filter_options(which returns the in-use codes andindustry_labels) orget_naics_catalogto resolve a description to a code before filtering search_sectionsreturns a top-levelstandard_id_labelsmap resolving thestandard_idvalues on the page to their taxonomy labels, so a client does not need a follow-upget_clause_taxonomycall just to name the nodes it matched. It resolves what the taxonomy tables contain: a small number of ids present on sections have no taxonomy row and are simply absent from the map, so look ids up in it rather than assuming every id on the page appearsmonetary_valuesis capped at 20 distinct values per section. When the cap drops any,monetary_values_truncatedistrue— read it before treating the list as every amount in the clauseget_agreement_trendsaccepts asectionsselector (ownership,target_industries,pairings,naics_catalog) and defaults to[ownership, target_industries]; the largepairings(every industry-by-industry cell) andnaics_catalog(full NAICS hierarchy) sections are opt-in, and the response echoessections_returned. It also acceptsyear_min/year_maxto slice the per-year arrays (ownership mix/deal size, target-industry distributions) to a window instead of returning every year.ownership.buyer_type_matrixandindustries.pairingscome from summary tables that are pre-aggregated across the whole corpus and carry no year dimension, so the window cannot apply to them. The echoedyear_filterstates this in-band: alongsideyear_min/year_maxit listsapplied_to(the dotted paths the window narrowed) andnot_applied_to(paths still covering all years), plus anoteexplaining why. Readnot_applied_tobefore quoting any figure from those sections as a windowed number- Agreement industry fields are stored as bare NAICS codes (e.g.
"334").get_agreement,list_agreements,get_agreements_batch, andsearch_sections(in per-resultmetadata) add a decodedtarget_industry_label/acquirer_industry_labelsibling, so clients no longer need a separateget_naics_cataloground-trip to read an industry; see the null-consistency note above for when the code does not resolve - The corpus is SEC-filed, predominantly public-target M&A exhibits sourced from EDGAR. Private-deal language is largely absent, so a language-frequency study built on this corpus inherits that skew;
target_typeis the closest available filter for public/private, not an exact proxy for deal population. The same underlying deal can also appear as multiple filings — both parties may file, and amendments or closing re-filings add further copies — and there is currently no deal-level dedupe key, so a filing count is not a deal count - For corpus-scale text analysis, filter with
list_agreementsusingfields: ["agreement_uuid", "url", ...]and fetch full agreement bodies from the returned EDGARurlout-of-band. Do not pull full texts through MCP responses at scale: a full-rowlist_agreementspage atpage_size=100already runs roughly 100k characters before any XML is attached, andget_agreement/get_sections_batchXML is larger still get_agreement_tax_clausesandget_section_tax_clausesreturn anextraction_status(found/no_tax_clauses/not_extracted) plus anextraction_noteso an emptyclauseslist is unambiguous:no_tax_clausesmeans clause extraction ran and found no tax-module clauses (a real absence), whilenot_extractedmeans no clauses of any module exist for the record, so the empty result is uninformative rather than a confirmed absenceget_agreement_tax_clausesandget_section_tax_clausesare page-paginated (page,page_size, default 25, max 200) and return the standard flat pagination block (total_count,total_pages,has_next, …). Both also accepttax_standard_id(dottedtax.*ids, parent ids expand to descendants) to return only clauses assigned to specific taxonomy nodes. This matters because most extracted tax-module clauses carry an emptystandard_idslist — an unfiltered agreement fetch can return several hundred clauses of full text.extraction_statusalways describes the record as a whole, never thetax_standard_idfilter, so a filter that matches nothing still reportsfoundrather than implying extraction never ranget_server_capabilitiesis the main machine-readable semantics surface; it includes auth guidance, field inventory, concept notes, and negative guidance about when not to use a tool- Feedback encouragement is deliberate and layered: the
initializeinstructions ask agents to report friction as it happens and to file a session-end wrap-up,get_server_capabilitiescarries aserver.feedback_tool/server.feedback_notepair plus a "report friction or gaps you encountered" workflow, and thesubmit_feedbacktool description spells out what a useful report contains (tool, arguments, expected vs actual — and praise for what worked). Any authenticated principal can submit; no extra scope is required - The server exposes a small set of MCP resources (
pandects://capabilities,pandects://auth-help,pandects://tools-manifest) that mirrorget_server_capabilitiesfor clients that prefer theresources/readprimitive over calling a tool.get_server_capabilitiesreportsserver.resources_supported: trueaccordingly; a test pins that declaration to whatresources/listactually serves, since the two had drifted apart - The server exposes curated MCP prompts (
compare_agreements,clause_family_survey,deal_trend_brief) as research templates; they orchestrate the primitive retrieval tools rather than introducing new functionality. Because they encode a tool sequence, they have to be revisited whenever the tools change:clause_family_surveypreviously taught thesearch_sections→get_section_snippetround-trip thatinclude_snippetremoved, andcompare_agreementsrouted general "quantified clause positions" to the tax-only tools. Both now match the current tool surface
Transport
POST /mcpis the primary JSON-RPC endpoint. It supports content negotiation: clients that advertiseAccept: text/event-streamreceive an SSE-framed response; clients that preferapplication/jsonreceive a plain JSON body. This matches the Streamable HTTP behaviour required by Claude Code.GET /mcpreturns an SSE retry probe for clients that opportunistically open a server-to-client stream.DELETE /mcpis accepted as an authenticated session-termination signal and returns204.initializeresponses carry anMcp-Session-Idheader. The server is stateless, so the id is informational — clients are not required to echo it, but Claude Code does.- Every response carries an
MCP-Protocol-Versionheader echoing the negotiated protocol version. - Advertised server capabilities:
tools,resources(listChanged=false, subscribe=false),prompts(listChanged=false), andlogging(logging/setLevelis accepted as a no-op).
Tool error codes
tools/call failures use distinct JSON-RPC error codes so a client can tell a fixable request from a server-side fault. The codes are identical across the JSON and SSE response paths:
-32602— invalid tool arguments (schema/validation failure or a malformed identifier). The offending field(s) and reason are folded into the top-level errormessage(e.g.Invalid tool arguments: metadata: Must be one of: ...), not just thedatapayload, so clients that surface onlymessagecan still self-correct. The full per-field marshmallow error tree remains available underdata. Argument rejections raised by a tool handler rather than by schema validation carry their reason too (e.g.Tool request was invalid. Invalid section_uuid: <value>); this applies only to messages the handlers explicitly author, so an unexpected400raised elsewhere in the stack stays generic and never leaks internal detail.-32002— the tool ran but the requested resource was not found (e.g. an unknown agreement or section UUID); the errordatais{"category": "not_found", "status_code": 404}. Verify the identifier with a search or list tool and retry — this is not a transient error.-32001— unauthenticated: no bearer token, or one that is invalid or expired. Distinct from-32003, which means the token is valid but lacks the scope the tool requires-32003— missing scope / authorization-32603— the tool result violated its advertised output schema-32004— a genuine tool failure (server-side)
In get_server_metrics, a not-found tool result is counted under its own not_found error category (not the generic http_exception), since a bad identifier is a client mistake rather than a server fault. Note that metrics counters are per-process: in a multi-worker deployment each get_server_metrics call reports only the worker that served it, not a server-wide total.
Progress notifications
When a tools/call request includes params._meta.progressToken and the client advertises Accept: text/event-stream, the server returns a multi-event SSE stream:
notifications/progresswithprogress=0,total=1, and aStarting <tool>messagenotifications/progresswithprogress=1,total=1, and a<tool> completemessage- The final
tools/callJSON-RPC result (or error)
This keeps intermediary proxies and client UIs aware of in-flight work on long calls. Clients that do not set a progress token, or do not accept SSE, receive the usual single-response behaviour.
OAuth discovery and Dynamic Client Registration
The server is protected by an embedded OAuth authorization server whose issuer lives under /v1/auth/oauth. To make OAuth discovery work with clients that implement RFC 8414 strictly (including Claude Code), authorization-server metadata is exposed at three locations:
GET /.well-known/oauth-authorization-server— host-root fallbackGET /.well-known/oauth-authorization-server/v1/auth/oauth— RFC 8414 host-root + issuer-path formGET /v1/auth/oauth/.well-known/oauth-authorization-server— issuer-prefixed form (original)
GET /.well-known/openid-configuration is also exposed at the host root for OIDC-leaning clients.
The metadata document advertises registration_endpoint (/v1/auth/oauth/register), so compliant clients can self-register via Dynamic Client Registration (RFC 7591) without a manual out-of-band step. Only public, PKCE (S256), authorization-code clients are supported.
Authentication
- MCP uses normal Pandects account login
- MCP does not use Pandects API keys
codex mcp addonly registers the server; in Codex,codex mcp login <name>starts OAuth- Auth failures return structured remediation metadata so clients can distinguish missing-token, expired-token, unverified-account, and unlinked-subject cases