petromcp: letting LLM agents read well logs

10 min read

I built an open-source MCP server that parses LAS well log files locally, behind a default-deny path allowlist with no network access. What it does, why the format defeats an LLM on its own, and what is not built yet.

petromcp is an MCP server that lets Claude read LAS well log files off your disk. You point your MCP host at it, name one directory as readable, and then you can ask "what's wrong with this well log?" with a file path attached and get an answer instead of a request to paste the data.

It publishes to PyPI as petroleum-mcp. The repo is still called petromcp and so is the server, since that is what I call it.

It exposes five tools and one prompt today:

ToolWhat it does
read_las_fileHeader-level summary. Well name, operator, depth range, curve list, gap summary. No curve data.
summarize_las_curvesPer-curve min, max, mean, stddev, gap percentage.
read_las_curveDepths and values for one curve, downsampled to 500 points unless you name a depth interval.
compare_well_logsCommon curves, depth overlap, unit consistency, issue flags.
convert_unitsft↔m, psi↔kPa, psi↔bar, bbl↔m3, degF↔degC, mD↔m2.
qc_a_well_log (prompt)Walks the model through a standard QC pass.

It runs locally over stdio. It makes no outbound network calls of its own. It can read nothing at all until you add a directory to ~/.petromcp/config.json. Python 3.10+, MIT, currently 0.4.0.

Setting it up

Nothing to clone. uvx fetches the package and runs it, so the only thing you edit is your MCP host's config. For Claude Desktop that is ~/Library/Application Support/Claude/claude_desktop_config.json on macOS:

{
  "mcpServers": {
    "petromcp": {
      "command": "uvx",
      "args": ["petroleum-mcp", "serve"]
    }
  }
}

Then tell it which directory it is allowed to read. This is the whole security model, so it is worth doing deliberately rather than pointing it at your home folder:

uvx petroleum-mcp config init
uvx petroleum-mcp config add-path ~/petroleum/wells

Restart the host. If you want to try it before aiming it at anything real, the repo ships synthetic files you can point it at instead:

uvx petroleum-mcp config add-path "$(pwd)/examples/sample_data"

Any path you have not added is refused, including via symlink. That is the next section.

I spent seven years doing production ML on petroleum data at SLB, most of it on artificial lift and equipment health. I have one granted US patent (US 12,291,957, ESP failure prediction and run-life estimation) and five pending in applied ML for energy. petromcp is the small piece of that world I kept wishing an assistant could see on its own.

What a LAS file actually is

LAS is the Log ASCII Standard from the Canadian Well Logging Society. It is a plain text file with ~-prefixed sections: a version block, a well block of header items, a curve block declaring mnemonics and units, and then an ASCII block that is one row per depth step. Here is a complete one, from the malformed-file fixture corpus in the repo:

~Version
 VERS.   2.0 : CWLS LOG ASCII STANDARD - VERSION 2.0
 WRAP.    NO : ONE LINE PER DEPTH STEP
~Well
 STRT.ft     5000.0000 : START DEPTH
 STOP.ft     5001.0000 : STOP DEPTH
 STEP.ft        0.5000 : STEP
 NULL.       -999.2500 : NULL VALUE
 WELL.       CRLF      : WELL
~Curves
 DEPT.ft : Depth
 GR  .GAPI : Gamma Ray
~ASCII
5000.0000 50.0
5000.5000 51.0
5001.0000 52.0

That is three depth samples and one curve. A real one is not that. The synthetic well the repo generates covers 5000 to 9000 ft at a 0.5 ft step with five curves, which is 8,001 rows and 537 KB on disk. That is a modest log. Log suites in production routinely carry twenty or thirty curves over longer intervals.

Why an LLM cannot just read one

Three things go wrong, and they compound.

Size. 537 KB of ASCII floats is roughly 130,000 tokens by a naive four-characters-per-token estimate. You can technically fit that in a large context window. You should not want to. The model is now paying full attention cost on 8,001 rows of numbers to answer a question whose answer is six numbers.

The interesting facts are aggregate. "Is this log usable?" resolves to per-curve statistics and gap coverage, not to any individual row. A model reading the raw ASCII block is doing column-wise arithmetic by eye across thousands of lines, which is exactly the thing language models are worst at.

The NULL sentinel. LAS marks missing data with a numeric sentinel declared in the header, conventionally -999.25. It is not NaN, it is not an empty cell, it is a number that looks like data. In the repo's synthetic well, the RHOB (bulk density) curve has a deliberate 160-row gap filled with -999.25, which is 2.0% of the file. Average that column naively and you get -17.63 g/cm³. Handle the sentinel and you get 2.399 g/cm³. Bulk density is never negative. A model that skims the ASCII block and reports a mean has no particular reason to notice which of those two numbers it just produced.

There is a fourth, quieter failure. lasio.read() defaults to latin-1, which means a UTF-8 LAS file from a Spanish, Norwegian, or French operator decodes to mojibake without raising anything. petromcp tries UTF-8 first and falls back, and there is a fixture named unicode_well_name.las asserting that Pozo-Ñoño survives the round trip.

What petromcp returns instead

The tools are thin wrappers over lasio that return frozen Pydantic models, sized for a context window. Here is read_las_file against the synthetic well, trimmed to two curves:

{
  "well_name": "SYNTH-01",
  "operator": "petromcp synthetic",
  "depth_start": 5000.0,
  "depth_stop": 9000.0,
  "depth_step": 0.5,
  "depth_units": "ft",
  "curves": [
    { "name": "GR", "units": "GAPI", "description": "Gamma Ray",
      "min_value": 11.38752, "max_value": 104.73833 },
    { "name": "RHOB", "units": "g/cm3", "description": "Bulk Density",
      "min_value": 2.25758, "max_value": 2.51322 }
  ],
  "total_points": 8001,
  "gap_summary": { "total_gaps": 0, "largest_gap": null, "gap_percentage": 0.0 }
}

And summarize_las_curves, which is where the QC signal lives:

{ "name": "GR",   "units": "GAPI",  "min": 11.38752, "max": 104.73833,
  "mean": 59.92176, "stddev": 16.29196, "gap_percentage": 0.0 }
{ "name": "RHOB", "units": "g/cm3", "min": 2.25758,  "max": 2.51322,
  "mean": 2.39906,  "stddev": 0.03897,  "gap_percentage": 2.0 }

RHOB reads 2.0% and every other curve reads 0.0%. That is the defect, stated in one field, in a payload of a few hundred tokens. The qc_a_well_log prompt then tells the model what to do with it: flag gaps above 1%, flag RHOB outside 1.8 to 3.0, flag a missing CALI, quote the tool output that justifies each flag.

read_las_curve is the escape hatch when the model wants actual values. It downsamples to 500 points by default and only returns every point when you pass an explicit depth_start and depth_stop. The response carries downsampled and original_count so the model knows it is looking at a subsample rather than quietly assuming it has everything.

There is one eval scenario wired into CI. It generates the synthetic well from a fixed seed, runs summarize_las_curves, and asserts the inserted defects come back: RHOB gap percentage above 1.0, every other curve below 0.5, exact unit strings on all five curves. CI runs ruff, pyright, the 67-test pytest suite, and that eval on every pull request. One scenario is not an eval suite. It is the scaffold for the ones I have not written yet.

The security posture, and why it is the actual product

Every file-reading tool routes through one function, and the default is deny:

def validate_path(target: Path | str, allowed: Sequence[Path | str]) -> Path:
    target_path = _resolve(Path(target))
    if not target_path.exists():
        raise FileNotFoundError(target_path)

    allowed_resolved = [_resolve(Path(a)) for a in allowed]
    for root in allowed_resolved:
        try:
            target_path.relative_to(root)
            return target_path
        except ValueError:
            continue
    raise PathNotAllowedError(msg)

The resolution happens before the check, so a symlink pointing out of an allowed directory does not escape. A fresh install has an empty allowed_paths list, which means petromcp can read nothing until you say otherwise. There is no --allow-all. Ask it for a file outside the list and the model gets the error, not the contents:

petromcp: path /private/etc/hosts is not in allowed_paths (symlinks are
resolved before this check, so the displayed path may differ from the literal
one you passed). Add the directory to ~/.petromcp/config.json and restart the
host.

Access logging is on by default, one line per call to ~/.petromcp/access.log with timestamp, tool name, and resolved path. petromcp itself opens no sockets: no telemetry, no phone-home, no runtime update check. Every sample file in the repo comes out of examples/sample_data/generate.py from a fixed seed, so nothing shipped in the repo is real well data.

I am being this specific because in this industry the security posture is the gate, not a feature. Operators treat well logs as competitively sensitive information. Log data is the basis for lease valuation and offset-well interpretation, and many US regulators grant a confidentiality period before a log becomes public record. A tool that reads that data and might mail it somewhere is not something anyone's IT organization will approve, and correctly so. DATA_PRIVACY.md is linked above the fold in the README for that reason, and it says plainly that the host application may forward tool results to its model provider, which is governed by the host's policy rather than by this server. That boundary should be stated, not glossed.

What is not built

The honest list, because a launch post that skips this is an ad.

  • DLIS. The binary log format, more common than LAS for modern wireline runs. dlisio is mature and this is the next slice. Not written.
  • SEG-Y. Headers only, ever. Full trace data is gigabytes and putting it in a context window is the wrong idea in several directions.
  • Pump cards. Dynacard CSVs and a diagnose_pump_card prompt. This is the piece closest to my own patent work and it is the one I most want to build.
  • Plotting resources. well_log_plot:// returning a Plotly payload is specified and unimplemented.
  • Host coverage. The install helper only knows Claude Desktop; Cursor and Codex CLI are stubs. Any MCP host that can run a stdio command works if you write the config by hand, which is the snippet above.
  • Unit comparison is strict. compare_well_logs does exact string matching on mnemonics and units, so G/CM3 and g/cm3 will report as a mismatch. Real files will surface false flags and that needs a normalization layer.

Two GitHub stars at time of writing. I am not going to pretend otherwise.

Why I think it is worth building anyway

Databricks published a piece in October 2025 on processing LAS files with AI functions that opens by saying traditional tools for accessing formats like LAS are "cumbersome and expensive, keeping vital subsurface insights out of reach." I agree with the diagnosis and disagree with where the fix belongs. Their answer is a warehouse you upload to. Mine is a parser that runs on the machine the files are already sitting on.

The MCP ecosystem has crude oil price APIs and at least one RAG proof of concept. Searching for a server that parses LAS, DLIS, or SEG-Y turns up nothing I can find. That gap is not surprising. The people who know these formats mostly do not ship open-source developer tooling, and the people who ship open-source developer tooling have no reason to have heard of a dynacard. I have spent long enough on both sides to find that annoying.

If you work with well logs, install it and point it at a directory of synthetic data first. If it does something wrong, file an issue. Anything tagged privacy goes to the front of the queue.

Fear has killed more dreams than failure ever will.
© 2026 Amey Ambade
Houston, TX
currently obsessing over: the long catalog of bossa nova standards