Skip to content

pivotkit-mcp

pivotkit-mcp is an MCP server: an AI client (Claude Code, Claude Desktop, VS Code and others) uses it to build pivot reports over data the operator names. The model never reads the data itself. It sees what the operator allows of each source (by default names, types and counts), writes a view, lets the server check it, and has the report written into a file or made into a link for the person who asked.

To set it up step by step, see Getting started: MCP server. This page is the reference.

npx -y pivotkit-mcp --source sales=sales.csv --output reports     # stdio, for a local client
npx -y pivotkit-mcp --config pivotkit-mcp.json --http             # Streamable HTTP on 127.0.0.1:3333

Node 22 or later. It brings the official MCP TypeScript SDK (@modelcontextprotocol/server, which speaks the protocol revision 2026-07-28 and the 2025 revisions) and pivotkit-server, which holds what it shares with the REST service (sources, policy, workers, output, sign-in) and brings pivotkit-report, pivotkit-cli's file handling, pivotkit-xlsx, ECharts 6.1.0 and jose (for OAuth). pivotkit-server serve runs this endpoint and the REST service on one port.

Tools

Tool What it does Writes
list_sources The sources the caller may use: id, title, whether a link or a schedule works -
describe_source A source's fields with their type, level and counts, the aggregators and filters each works with, example views from its own fields -
suggest_view A draft view from a source's fields (marked Draft:), with what each part took and could take instead (picks); a layout replaces parts of it -
get_view_guide How to write a view: its keys, the vocabulary (aggregators, filter types, date groupings), five example views -
validate_view Errors and warnings of a view (or a layout: a view by field names) against a source, each with its place and, for a misspelt name, the name meant (suggestion) -
describe_view A view in plain parts (rows, columns, values, filters in words) with the same findings -
render_view The report of a view or a layout as xlsx (default), html, csv, json or svg, into the output directory; inline where the policy allows a new file
create_link A link that opens the view in the source's report page (only for sources with link) -
draft_schedule A draft of a report that runs on a schedule (when, formats, recipients by name), for a person to approve in the host; runs and sends nothing a new file

Every tool has an input and an output JSON Schema (draft 2020-12) and returns its result as structured content and as one text block: a short sentence with counts and ids (never a name or value from the data), then the JSON. A view with errors is not a tool error: ok or valid is false and findings say what to fix. The tools are one table shared with the command line (pivotkit-report/operations); the REST service offers the same operations with the same policy to programs.

The server also gives the model short instructions when it connects, a prompt pivotkit_report (for clients with a prompt picker) and a skill (see the skill).

What a source is

source is either the id of a source the operator configured (sales) or, when the operator allows files, file: and a path inside the allowed directories (file:q3.csv, file:/srv/reports/q3.csv). A relative path is looked up in each allowed directory in turn. Formats: CSV, TSV, TXT, JSON and XLSX, read as the command line reads them.

A file: path to the file of a configured source is that source: its scopes and its policy hold. The output directory may not lie inside an allowed directory (nor hold one), so the model cannot read back a report it was not shown. Put only data files into an allowed directory: the first line of any CSV, TSV, TXT or JSON file there becomes field names, which the model sees at every level.

A file named by file: is checked on its real path (symbolic links and .. resolved) against the allowed directories, then opened once without following a link at its end and checked again on the open file. A file with a second name (a hard link) is refused unless files.hardLinks is true. A file that is missing, outside the directories or replaced while it is read gets the same answer, FILE_NOT_AVAILABLE, so a model cannot probe which files exist elsewhere.

Exposure policy

The operator sets, per field, how much of it the model may see. A source's policy goes over the server's policy; field names are matched without regard to case.

Level describe_source shows Findings Results
show name, type, counts, up to items values, the smallest and largest value names and values (a misspelt item gets the item meant) csv and json come back inline when they fit inline
summary (default) name, type, counts names; no check of an item against the data written to a file only; rows, columns and size withheld
hide not listed; hiddenFields counts them never named the column is dropped when the data is read
Key Default What it does
default "summary" The level of a field without its own
fields {} Levels by field name: { "Region": "show", "Customer": "hide" }
items 20 Values listed per show field
rows true Show the number of rows
counts true Show distinct and blank counts per field
inline { "cells": 500, "bytes": 32768 } The largest csv or json result that may come back inline; false: never
results false The REST service's file route may hand out the rendered file (downloads)

A profile (policy.profiles.<name>, a policy of the same shape) gives one caller more or less than this: bound to a static token (auth.tokens[].profile), an OAuth client (auth.clientProfiles) or scope (auth.scopeProfiles), laid over each source's policy; a field a source hides stays hidden in every profile, and a caller without a profile gets the server's policy. See profiles.

A view that uses a field the model may not see in full (rows, columns, values, filters, sorting, a date grouping's source field) has its result withheld: render_view writes the file and returns its name, withheld: ["rows", "columns", "bytes", "content"] and a message for the user, but no count, size or content, since even a row count could answer "is this person in the data?".

suggest_view reads only what is not hidden. Its draft and picks name fields, never values or counts; the one thing they tell about a field's values is the date grouping its span gives (a span of up to two years is grouped by month), as a layout with a date on an axis does.

summary is not confidentiality

summary keeps values out of what the model reads directly. A model that writes filters over other fields and looks at what comes back inline can still learn about the data, and every number in a report is real: a small group can identify a person. For a field the model must not learn anything about, use hide, or leave the column out of the data. Field names are data too. Pseudonymised values are not offered.

Configuration

The runner reads a configuration file (JSON) and its flags; a flag wins, and a list given as flags adds to the file's. Paths in the file are relative to the file. The configuration is read at start: a change needs a restart, except token files (read on every request). An unknown key or value is an error that names the key and the one probably meant.

Key Flag Default What it is
sources.<id>.file --source id=file - A data file (CSV, TSV, TXT, JSON, XLSX)
sources.<id>.title, .description - the id What list_sources says about it
sources.<id>.sheet - first visible A workbook's sheet
sources.<id>.policy - the server's The source's exposure policy
sources.<id>.link, .linkKey - none The page that shows this source's reports, for create_link; the report's key there
sources.<id>.template - built-in An HTML print template for render_view's html
sources.<id>.scopes - none OAuth or token scopes a caller needs to see and use it
files.allowDirs --allow-dir dir none Directories the model may name files in (file:)
files.list --list-files false list_sources lists the data files of the allowed directories (not their subdirectories)
files.hardLinks - false Read files that have a second name
files.scopes, files.policy - none, the server's Scopes and policy of file: sources
output.dir --output dir none Where reports and drafts are written
output.baseUrl - none The address the directory is served at: results carry a url, file names start with 22 random characters
output.maxFiles - 1000 Files the directory may hold before render_view refuses (NO_OUTPUT)
output.maxAgeDays - none Delete reports and drafts older than this from the top level of the output directory, at start and every hour (see REST)
policy - see above The server's exposure policy, with its profiles
limits - pivotkit-core's, files 50 MiB pivotkit-core's limits (rows, fileBytes, unpackedBytes, ...)
locale, timeZone - en-US, UTC Numbers and dates of reports; the time zone of dates in the data
compute.workers - 2 Reports computed at once (worker threads)
compute.timeMs - 30000 The longest a call may compute; the worker is then stopped and replaced (TIMEOUT)
compute.memoryMb - 1024 Memory of a worker (LIMIT_EXCEEDED beyond)
compute.queueMs - 10000 How long a call waits for a free worker before BUSY
http.host, http.port --host, --port 127.0.0.1, 3333 Where --http listens
http.publicUrl --public-url url none The server's address as clients reach it (behind a proxy)
http.allowHosts --allow-host name localhost names Host names the server answers to (DNS rebinding protection)
http.allowOrigins --allow-origin name none Browser origins (host names) allowed to call
http.page - on The quick-start page; { "sources": true } lists source ids on it; false turns it off
http.graceMs - 25000 How long a stop (SIGTERM) waits for running requests
auth.* --auth mode none See Authentication
log --log level info info, debug or off
- --config file $PIVOTKIT_MCP_CONFIG The configuration file
- --check-config - Print what would be exposed (sources, fields by level, files, output, auth) and exit

The same file configures pivotkit-server, which reads rest.* and http.enable too; pivotkit-mcp ignores them.

{
  "sources": {
    "sales": {
      "file": "data/sales.csv",
      "title": "Sales",
      "description": "Orders since 2024, one row per order line",
      "link": "https://reports.example.com/sales",
      "policy": { "fields": { "Region": "show", "Product": "show", "Customer": "hide" } }
    }
  },
  "output": { "dir": "reports" },
  "policy": { "default": "summary", "items": 20 }
}

As a library (the SDK's stdio entry is serveStdio from @modelcontextprotocol/server/stdio):

import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { createMcpContext, listen } from "pivotkit-mcp";

const context = await createMcpContext({ sources: { sales: { load: async () => rows, version: () => rowsVersion } }, output: { dir: "/srv/reports" } });
serveStdio((ctx) => context.server(ctx));                               // stdio
// or HTTP with every check of this page, on node:http:
const server = await listen({ sources: { sales: { file: "/srv/data/sales.csv" } }, http: { port: 3333 } }, { env: process.env, stderr: process.stderr });

createMcpContext(options) checks the options (validateOptions), resolves the directories and starts the workers; context.server(ctx) builds a fresh McpServer for the SDK's serveStdio or createMcpHandler; await createHttpHandler(context) gives { fetch, close } with every check of this page; loadConfig(file) reads a configuration file into options. A library host may give a source load: async () => rows (rows or columnar data) and version: () => key instead of file; such sources run in the host's thread, not in a worker. createHttpHandler(context, { env }) reads static tokens from env (default process.env). pivotkit-mcp/policy exports the exposure policy (resolvePolicy, levelOf, exposeSource, exposeFindings, touchesClosed) for another front end over the same sources; it is pivotkit-server/policy under another name.

Limits and errors

Views from the model are checked with pivotkit-core's limits for views from outside (outsideLimits), data with its limits; the server's own default for files is 50 MiB (fileBytes, unpackedBytes). A call's input may hold at most 10,000 array elements and object members; an HTTP body at most 2 MiB.

A tool error is a result with isError and the text CODE: message (an input the tool's schema refuses is answered by the SDK before the tool runs, as Input validation error: ...); the message names a file by its base name only and holds no file content.

Code When
INVALID_INPUT An input the schema allows but the tool does not (a recipient that is an address, a format, an input nested deeper than 64 levels)
SOURCE_NOT_FOUND No such source, or one the caller lacks the scopes for (the same answer)
FILES_NOT_ALLOWED file: without allowed directories, or without the scopes for files
FILE_NOT_AVAILABLE A file missing, outside the allowed directories, or changed while read
NOT_REGULAR_FILE, HARD_LINK, UNSUPPORTED_FORMAT, INVALID_FILE The file is a directory or special file, has a second name, is no data format, or cannot be read
LIMIT_EXCEEDED A file, a view or the work over a limit; the worker's memory
NOT_ALLOWED A tool the caller's token has no scope for
NO_OUTPUT No output directory for a file that must go there, or the directory is full
NO_LINK, NOT_SCHEDULABLE A link for a source without a page; a schedule over a file: source
BUSY, TIMEOUT No worker free within queueMs; a call over timeMs
INTERNAL Anything else: "internal error", the details only in the log with --log debug

Logging

One JSON line per event on stderr (on stdio, stdout is the protocol): started (version, transport, address, auth mode, the number of sources, whether files and an output directory are on), call (tool, source id or the base name of a file, client, ok, error code, time in ms), refused (an HTTP request refused by the Host, Origin or token check, with its status) and warning. The client is the static token's name or the OAuth token's client id, never the person's id. No line holds a view, a value, a token or a full path. The log is the supervisor's to keep and rotate.

Streamable HTTP

--http serves the MCP endpoint at /mcp (POST; GET and DELETE as the SDK answers them), with these checks in order:

  1. Host: only the localhost names, the public URL's host and --allow-host (protection against DNS rebinding, as the transport spec asks). It cannot be switched off.
  2. Origin: a request with an Origin header (a browser) is refused with 403 unless its host name is in --allow-origin; a client that sends none (every non-browser client) passes.
  3. Authentication for /mcp (below).

Host, Origin, the declared size and the token are checked from the headers before the body is read; at most 256 connections are open at once, and a request has 30 seconds. On SIGTERM the server takes no new connection and lets running requests finish for up to http.graceMs.

Without authentication: /healthz ({ "ok": true }, for a supervisor or a proxy), the OAuth discovery documents, and the quick-start page at /: what the server is, its endpoint, client configurations to copy, the auth mode and the tools. A browser that opens /mcp is sent there. The page lists source ids only when http.page.sources is true (default only with auth none), and only sources without scopes, never paths, fields or data; it loads nothing from elsewhere and has no script (Content-Security-Policy: default-src 'none').

The server listens on 127.0.0.1 unless --host says otherwise, and refuses another address without authentication unless --allow-unauthenticated says the network is trusted. There are no sessions: every request gets a fresh server from the shared context. Behind a reverse proxy: keep the server on 127.0.0.1 when the proxy runs on the same machine, terminate TLS at the proxy, forward /mcp, /.well-known/ and /, keep the Host header (or add the proxy's name with --allow-host), set --public-url, and turn response buffering off for /mcp. The server reads no X-Forwarded-* header: client addresses are in the proxy's log. For nginx:

location ~ ^/(mcp|\.well-known/|healthz$|$) {
    proxy_pass http://127.0.0.1:3333;
    proxy_set_header Host $host;
    proxy_buffering off;
    proxy_read_timeout 60s;
}

Authentication

Mode For How
none stdio, HTTP on loopback No check. stdio always: the authorization spec says a stdio server takes credentials from its environment.
token a team, a proxy, PowerShell Universal Authorization: Bearer <token>
oauth any OAuth 2.1 / OpenID Connect provider The server is an OAuth protected resource

Static tokens

The token comes from $PIVOTKIT_MCP_TOKEN, or several named tokens from auth.tokens, each from an environment variable or a file (read on every request, so a token is rotated without a restart), each with its own scopes (a token without scopes holds every scope). A token has at least 32 characters; make one with node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))". Tokens are compared as SHA-256 digests in constant time, never logged, and refused in the address (?access_token=) and when a request carries two Authorization headers. The token's name is the client in the log.

{ "auth": { "mode": "token", "tokens": [
    { "name": "analysts", "env": "PIVOTKIT_TOKEN_ANALYSTS", "scopes": ["sales", "hr"] },
    { "name": "viewers", "file": "/run/secrets/pivotkit-viewers", "scopes": ["sales"] } ] } }

OAuth 2.1

The server follows the MCP authorization spec (revision 2026-07-28) as a protected resource:

  • It serves Protected Resource Metadata (RFC 9728) at /.well-known/oauth-protected-resource/mcp, naming the issuer as its authorization server, and the issuer's own metadata at /.well-known/oauth-authorization-server for older clients.
  • A request without a valid token gets 401 with WWW-Authenticate: Bearer resource_metadata="..." scope="..."; a token without the required scopes gets 403 insufficient_scope naming every scope the call needs.
  • An access token must be a JWT signed by a key of the issuer's JWKS (RS256, PS256, ES256 or EdDSA by default; never none, never HMAC), with this issuer, an audience of this server (the resource, RFC 8707) or one of audience, and an expiry; 30 seconds of clock difference are allowed. Why a token failed stays on the server. The token is never passed on.
  • At start the server reads the issuer's metadata (RFC 8414, then OpenID Connect discovery; its issuer must match exactly, its jwks_uri must be on the issuer's origin unless jwksUri is set), with a time limit and no redirects. Until it has it, /mcp answers 503 and the server keeps trying. Keys are fetched from the JWKS and kept for 10 minutes; a new key is fetched at most every 30 seconds. A token stays valid until it expires (no revocation check).
Key Default What it is
auth.issuer - The issuer, exactly as its metadata writes it (required)
auth.resource - The canonical address of the MCP endpoint, e.g. https://reports.example.com/mcp (required)
auth.audience [resource] The aud values accepted, for providers that put a client id or an api:// URI there
auth.requiredScopes none (a warning at start) Scopes every request needs, e.g. ["pivotkit"]; also the scopes the 401 asks for
auth.toolScopes none Scopes per tool: { "render_view": ["pivotkit:render"], "draft_schedule": ["pivotkit:schedule"] }
sources.<id>.scopes, files.scopes none Scopes a caller needs to see a source (without them it is not listed and is SOURCE_NOT_FOUND)
auth.scopesSupported requiredScopes What the metadata lists
auth.scopeClaim scope, else scp The claim the scopes are read from
auth.algorithms RS256, PS256, ES256, EdDSA Accepted signature algorithms
auth.jwksUri the issuer's Another place for the keys
auth.requireTyp none Require a token type, e.g. at+jwt (rejects ID tokens where the provider marks access tokens)
auth.clockTolerance 30 Seconds of clock difference
auth.allowInsecureIssuer false Accept an http:// issuer: for a test issuer on this machine only
auth.clientProfiles, auth.scopeProfiles none The policy profile of an OAuth client id or scope (profiles)

A token stays valid until it expires: keep the provider's access token lifetime short (minutes), so taking a person's access away takes effect soon. Set requiredScopes, so not every token the provider issues for this audience works, and requireTyp: "at+jwt" where the provider marks its access tokens so (RFC 9068), so an ID token is refused. Why a token was refused is logged with --log debug (jose's reason and the claim, never the token); the client is the token's azp or client_id (client when it has neither).

Opaque tokens (checked by introspection) are not supported: the provider must issue JWT access tokens. How a client registers (Client ID Metadata Documents, a pre-registered client or Dynamic Client Registration) is between the client and the provider: the provider needs a public client with PKCE, the scopes, and an audience the server accepts.

Examples (the issuer's form and what lands in aud differ by provider and setting: check them in your provider's documentation and in a decoded access token of your own):

{ "auth": { "mode": "oauth",
    "issuer": "https://auth.example.com/application/o/pivotkit/",
    "resource": "https://reports.example.com/mcp",
    "audience": ["<the provider's client id>"],
    "requiredScopes": ["pivotkit"] } }
{ "auth": { "mode": "oauth",
    "issuer": "https://login.example.com/realms/example",
    "resource": "https://reports.example.com/mcp",
    "requiredScopes": ["pivotkit"] } }

An audience mapper on the client scope puts https://reports.example.com/mcp into aud.

{ "auth": { "mode": "oauth",
    "issuer": "https://login.microsoftonline.com/<tenant id>/v2.0",
    "resource": "https://reports.example.com/mcp",
    "audience": ["api://<application id>", "<application id>"],
    "requiredScopes": ["Reports.Read"] } }

The issuer is tenant-specific (not /common), and of this form for version 2 access tokens; the scopes arrive in scp.

The skill

skills/pivotkit-reports/SKILL.md in the package teaches a model the workflow: describe the source, write a view from its fields, validate and use the suggestions, render or link, and what level and withheld mean. The server offers it as MCP skills to clients that support them, as the prompt pivotkit_report, and in short as its instructions. For clients that read skill folders, see Getting started.

Security

See Security: the MCP server for what the server protects against and what it does not.