MCP Integration

LINE exposes its queueing analysis capabilities as a Model Context Protocol (MCP) server, allowing Large Language Models (such as Claude) to build, solve, and analyze queueing models conversationally. Instead of writing code manually, you describe your system in natural language and the LLM calls LINE tools to produce quantitative results.

Setup

Installation

Install the LINE solver Python package. The MCP server and the MCP SDK come with it, and the installation puts a line-mcp command on your path:

pip install line-solver

Check that the command is on your path:

which line-mcp

The server speaks MCP over stdin and stdout, so starting it by hand simply waits for a client; it is meant to be launched by the MCP client, not run interactively.

Or clone the repository and install in development mode:

git clone https://github.com/imperial-qore/line-solver.git
cd line-solver/python
pip install -e .

Claude Desktop Configuration

Add the following to your claude_desktop_config.json:

{
  "mcpServers": {
    "line-solver": {
      "command": "line-mcp"
    }
  }
}

Claude Code Configuration

For Claude Code (CLI) the shortest route is the command line:

claude mcp add line-solver -- line-mcp

If line-mcp is not on the path Claude Code sees, which is the usual case when the package was installed into a virtual environment, give the executable by absolute path and state the scope explicitly:

claude mcp add line-solver -s local -- [YOUR_PATH]/line-mcp/.venv/bin/line-mcp

Here -s local keeps the entry private to you in the current project; -s user would make it available in every project, and -s project would write it into the .mcp.json below.

To share the server with everyone working on a project, declare it instead in a .mcp.json file at the project root:

{
  "mcpServers": {
    "line-solver": {
      "type": "stdio",
      "command": "line-mcp"
    }
  }
}

Claude Code does not read mcpServers from .claude/settings.json; a server declared there is ignored without a warning. Run claude mcp list to confirm the server was picked up. A project-scoped server out of .mcp.json is listed as pending approval until it has been approved once in an interactive session.

If line-mcp is not on the path your MCP client sees, give its absolute location instead, for instance the one which line-mcp prints. Running from a source checkout without installing the package also works: use "command": "python" with "args": ["/path/to/line-solver/python/mcp_server.py"].

Available Tools

ToolDescriptionCode Required
build_networkBuild ANY network from a description: open, closed or mixed, many stations, many classesNo
build_topologyBuild a multi-station network from a demand matrix, with no routingNo
build_from_galleryBuild one of the 71 ready-made gallery modelsNo
list_galleryThe gallery catalogue, with each model's argumentsNo
convert_modelConvert a model to LINE json, JMT, PNML, .lqnx or PythonNo
sweep_modelSweep any field of any model descriptionNo
analyze_queueBuild and solve ONE station from arguments: open, closed or mixedNo
analyze_networkBuild and solve a multi-station network from a demand matrixNo
sweep_parameterSweep one parameter of a single station and collect metricsNo
solve_model_fileSolve a model document: the whole line-cli command lineNo
find_solverWhich solvers and methods can analyze a model, and why notNo
list_solversThe vocabulary: solvers, analyses, input and output formatsNo
solve_line_modelExecute arbitrary LINE Python code (sandboxed)Yes
compare_solversRun the same model with multiple solvers side-by-sideNo
visualize_modelGenerate a Mermaid diagram of a queueing networkNo
list_examplesBrowse available example models by categoryNo
get_example_codeRetrieve source code of a named exampleNo
check_installEnvironment check: which optional backends are reachableNo
get_versionLINE version, interpreter and examples directoryNo
clear_sessionRemove a persistent code sessionNo

Building Models Without Code

The analyze tools build a model and answer with its numbers. The builders below REGISTER one instead, and reach shapes no demand matrix expresses: explicit routing, fork/join, class switching. Each returns a model_id; pass that to solve_model_file for any analysis, or to find_solver, visualize_model, compare_solvers or convert_model.

build_network takes a description of stations, classes, service and routing. Open and closed classes may appear together, which is what makes a model mixed; what needs this tool rather than analyze_queue or analyze_network is the explicit routing:

build_network(
  stations='[{"name":"Src","type":"Source"},'
           '{"name":"Q1","type":"Queue","scheduling":"PS"},'
           '{"name":"Q2","type":"Queue"},'
           '{"name":"Snk","type":"Sink"},'
           '{"name":"Think","type":"Delay"}]',
  classes='[{"name":"web","type":"Open"},'
          '{"name":"batch","type":"Closed","population":4,"refstation":"Think"}]',
  service='{"Q1":{"web":"exp(4.0)","batch":"exp(2.0)"},'
          '"Q2":{"web":"exp(3.0)","batch":"exp(2.5)"},'
          '"Think":{"batch":"exp(0.5)"}}',
  arrival='{"web":"exp(1.0)"}',
  routing='[["Src","Q1","web",1.0],["Q1","Q2","web",1.0],["Q2","Snk","web",1.0],'
          '["Think","Q1","batch",1.0],["Q1","Q2","batch",1.0],["Q2","Think","batch",1.0]]')

A station may carry scheduling, servers and capacity, and a Join also carries fork, naming the Fork station it closes: the pairing is a declaration rather than something read off the routing, since a model may nest two fork-join pairs. A class may carry a priority. A routing hop is [from, to, class, prob], or [from, to, from_class, to_class, prob] to switch class on the hop. arrival is keyed station → class → distribution, and by class alone when the model has exactly one Source.

Distributions are written name(args): exp(2.0), erlang(3.0, 2), hyperexp(0.5, 1.0, 4.0), det(0.25), cox2(1.0, 2.0, 0.5), gamma(2.0, 0.5), replayer('trace.txt'). The name is matched against LINE's own distribution classes, so every distribution the library publishes is available. A bare number is refused, because a rate and a mean are not the same thing.

build_topology skips routing entirely and takes a demand matrix, which is the textbook (N, Z, D) input:

build_topology(kind="cluster_mixed", demands='[[0.4, 0.5], [0.3, 0.2]]',
               population='[3]', think_time='[2.0]', arrival_rates='[1.0]',
               scheduling="PS")

kind is tandem (open chain), cyclic (closed loop), cluster (open fan-out), cluster_closed or cluster_mixed. population and think_time are per closed class, arrival_rates per open class. scheduling defaults to PS and accepts a per-station JSON list, and servers is a JSON list of server counts per station.

list_gallery catalogues the 71 ready-made models in line_solver.gallery with the arguments each takes, covering fork-join, caches, layered networks, finite-capacity regions and random environments:

list_gallery()
build_from_gallery(name="cqn", params='{"M": 3}')

sweep_model varies any field of any builder's arguments, named by a dotted path. A field holding an integer is swept with integers; for a field that is not numeric, give values as a JSON list:

sweep_model(param="population.0", values="2:20:2", builder="topology",
            args='{"kind":"cyclic","demands":[[0.5],[0.2]],"population":[2]}')

sweep_model(param="service.Q.c", values='["exp(2.0)", "exp(4.0)", "exp(8.0)"]',
            builder="network", args='{...}')

builder is network, topology or gallery, and args carries that builder's arguments as one JSON object, with the nested parts as real json rather than as strings. Each point is solved with solver (default auto) and reported under analysis (default avg).

convert_model renders a model as the LINE json interchange document, a JMT .jsimg, a PNML net, a layered .lqnx, or the equivalent Python script. The json document round-trips: hand it back to solve_model_file as content and it solves identically.

convert_model(target="json", model_id="m1")
convert_model(target="python", model_id="m1", name="my_model")

Solving Model Files

The code tools build a model out of Python. A command line does not: it reads a document, picks a solver by method name, asks for one of the analyses the library publishes and renders the answer. solve_model_file is that surface, so a client holding a model file need not paste it into code, and every analysis LINE publishes is reachable without writing the getter call by hand.

solve_model_file(file="model.json", solver="mva", analysis="avg")
solve_model_file(file="webapp.jsimg", solver="ctmc", analysis="prob", node=1, state="2")
solve_model_file(file="database.lqnx", solver="ln", analysis="avg", output="json")
solve_model_file(content=document_text, solver="nc", analysis="normconst")

The model comes as file (a path), as content (the document itself), or as the model_id of a model built this session, which reaches every analysis below exactly as a document does. A document may be in any format LINE reads: json (LINE's own interchange model, carrying a Network, a LayeredNetwork or an Environment), jsim/jsimg/jsimw (JMT), lqnx/xml (layered), pnml (place/transition nets), and mat/pkl by path. The format follows from the extension or from the document's first bytes unless input_format states it.

analysis takes one name or a comma-separated list, and reaches the average, system, stage, chain, node and node-chain tables; the cache and per-item tables; the retrial orbit, the two loss views and the deadline-miss table; the normalizing constant and the subnetwork busy period; response-time and passage-time distributions and percentiles; the transient tables; the state probabilities; simulated sample paths; rewards; sensitivities; and the SolverUQ interval. Every numeric control the command line carries is an argument too, and anything else SolverOptions understands may be passed as a JSON object in options. A few analyses are pointed at part of the model or need a parameter of their own: node and state for the state probabilities, class_idx for prob-marg, node and events for the sample paths, percentiles for perct-respt, and reward_name for reward-value.

When it is not obvious which solver fits, find_solver reports one row per (solver, method) pair, saying whether it runs, whether it is exact, approximate, a bound or a simulation, and which measures it answers; show_all=True keeps the refused pairs and the reason each was refused. Its Method column is the method name to hand straight back to solve_model_file, and list_solvers prints the full vocabulary both of them accept.

Quick-Start Examples

These tools require no code: give the parameters and the model is built, solved and thrown away in one call. They are not a reduced world. Any distribution the library publishes, any scheduling discipline by name, any number of classes, open, closed or mixed, and the same solver, method and analysis vocabulary solve_model_file takes, plus seed, samples, cutoff, timespan and options. The default solver is auto, and the header names the one that actually ran.

One Station

Prompt: “Analyze an M/M/1 queue with arrival rate 2 and service rate 5.”

The LLM calls analyze_queue(arrival_rate=2.0, service_rate=5.0) and returns:

Model: M/M/1
  scheduling        = FCFS
  servers (k)       = 1
  class Class1      = open, arrival exp(2.0), service exp(5.0)
  utilization (rho) = 0.400000
  solver            = auto -> MVA
Station  JobClass     QLen  Util    RespT   ResidT  ArvR  Tput
Queue    Class1    0.66667   0.4  0.33333  0.33333     2     2
Source   Class1          0     0        0        0     0     2

A class with an arrival is open and is fed by a Source; a class with a population is closed and circulates through a Delay holding its think time, or straight back into the queue when there is none. So a closed system is the same tool, said with a population instead of an arrival, and the open/closed split that used to need two tools is gone.

# "10 users, 5 seconds think time, service rate 2":
analyze_queue(population=10, think_time=5.0, service_rate=2.0)

Several classes are a JSON list, and they may be open and closed at once, each with a distribution and the station with a discipline of its own:

analyze_queue(
  classes='[{"name":"web","arrival_rate":1.0,"service_rate":4.0},'
          '{"name":"batch","population":4,"think_time":2.0,'
          '"service":"erlang(2.0, 3)"}]',
  scheduling="PS")
Model: mixed queue, 2 classes, k=1
  scheduling        = PS
  servers (k)       = 1
  class web         = open, arrival exp(1.0), service exp(4.0)
  class batch       = closed, N=4, think exp(0.5), service erlang(2.0, 3)
  utilization (rho) = 0.250000
  solver            = auto -> CTMC
Station  JobClass     QLen     Util   RespT  ResidT     ArvR     Tput
Queue    web        1.3379  0.24997   1.338   1.338  0.99988  0.99988
Queue    batch      3.0153  0.73849  6.1247  6.1247  0.49233  0.49233
Source   web             0        0       0       0        0  0.99988
Think    batch     0.98465  0.98465       2       2  0.49233  0.49233

Several Stations

Prompt: “Eight jobs circulating between two stations with demands 0.5 and 0.3.”

analyze_network takes the textbook (N, Z, D) input: one service demand per station per class, with the topology supplying the routing. kind is tandem, cyclic, cluster, cluster_closed or cluster_mixed.

analyze_network(kind="cyclic", demands="[[0.5],[0.3]]", population="[8]")
Model: cyclic(D=2x1)
  kind           = cyclic
  stations       = 2
  classes        = 1
  scheduling     = PS
  population (N) = [8]
  solver         = auto -> MVA
Station  JobClass    QLen     Util   RespT  ResidT    ArvR    Tput
Queue1   Class1    6.5916  0.99321  3.3183  3.3183  1.9864  1.9864
Queue2   Class1    1.4084  0.59593   0.709   0.709  1.9864  1.9864

Parameter Sweep

Prompt: “How does queue length change as arrival rate goes from 0.5 to 2.5 in steps of 0.5, with service rate 5?”

sweep_parameter varies one of arrival_rate, service_rate, servers, population, think_time or queue_capacity over the model analyze_queue builds, and takes every other argument of that tool. A point that fails is reported with its reason under the table rather than dropped.

sweep_parameter(param="arrival_rate", values="0.5:2.5:0.5", service_rate=5.0)

Custom Models

The solve_line_model tool executes arbitrary LINE Python code in a sandboxed environment with from line_solver import *, numpy, and pandas pre-loaded.

Open Network

model = Network('M/M/1')
source = Source(model, 'Source')
queue = Queue(model, 'Queue', SchedStrategy.FCFS)
sink = Sink(model, 'Sink')

oclass = OpenClass(model, 'Class1')
source.setArrival(oclass, Exp(1.0))
queue.setService(oclass, Exp(2.0))

model.link(Network.serial_routing([source, queue, sink]))
avg_table = MVA(model).avg_table()
print(avg_table)

Closed Network

model = Network('ClosedQN')
delay = Delay(model, 'Think')
queue = Queue(model, 'Queue', SchedStrategy.PS)

cclass = ClosedClass(model, 'Users', 5, delay)
delay.setService(cclass, Exp(1.0))
queue.setService(cclass, Exp(3.0))

model.link(Network.serial_routing([delay, queue]))
avg_table = MVA(model).avg_table()
print(avg_table)

Multiclass Network

model = Network('Multiclass')
source = Source(model, 'Source')
queue = Queue(model, 'Queue', SchedStrategy.FCFS)
sink = Sink(model, 'Sink')

class1 = OpenClass(model, 'Web')
class2 = OpenClass(model, 'API')

source.setArrival(class1, Exp(1.0))
source.setArrival(class2, Exp(0.5))
queue.setService(class1, Exp(4.0))
queue.setService(class2, Exp(3.0))

model.link(Network.serial_routing([source, queue, sink]))
avg_table = MVA(model).avg_table()
print(avg_table)

Advanced Tools

Solver Comparison

Run the same model with multiple solvers and compare results:

# Using compare_solvers tool with solvers="MVA,NC,MAM"
model = Network('M/M/1')
source = Source(model, 'Source')
queue = Queue(model, 'Queue', SchedStrategy.FCFS)
sink = Sink(model, 'Sink')
oclass = OpenClass(model, 'Class1')
source.setArrival(oclass, Exp(1.0))
queue.setService(oclass, Exp(2.0))
model.link(Network.serial_routing([source, queue, sink]))

Model Visualization

Generate Mermaid diagrams showing network topology, node types, and class information:

# Using visualize_model tool
model = Network('WebApp')
source = Source(model, 'Arrivals')
lb = Queue(model, 'LoadBalancer', SchedStrategy.PS)
app = Queue(model, 'AppServer', SchedStrategy.FCFS)
sink = Sink(model, 'Departures')
oclass = OpenClass(model, 'Requests')
source.setArrival(oclass, Exp(5.0))
lb.setService(oclass, Exp(100.0))
app.setService(oclass, Exp(10.0))
P = model.initRoutingMatrix()
P.set(oclass, oclass, source, lb, 1.0)
P.set(oclass, oclass, lb, app, 1.0)
P.set(oclass, oclass, app, sink, 1.0)
model.link(P)

which returns:

graph LR
    N0(["Arrivals"])
    N1["LoadBalancer"]
    N2["AppServer"]
    N3(["Departures"])
    N0 --> N1
    N1 --> N2
    N2 --> N3
    style N1,N2 fill:#2196F3,color:#fff,stroke:#2196F3
    style N3 fill:#F44336,color:#fff,stroke:#F44336
    style N0 fill:#4CAF50,color:#fff,stroke:#4CAF50

    %% Classes: Requests (open)

rendered by GitHub, Notion, Mermaid Live Editor, and other tools as:

graph LR
    N0(["Arrivals"])
    N1["LoadBalancer"]
    N2["AppServer"]
    N3(["Departures"])
    N0 --> N1
    N1 --> N2
    N2 --> N3
    style N1,N2 fill:#2196F3,color:#fff,stroke:#2196F3
    style N3 fill:#F44336,color:#fff,stroke:#F44336
    style N0 fill:#4CAF50,color:#fff,stroke:#4CAF50

    %% Classes: Requests (open)

Node shape carries node type: a stadium is a Source or Sink, a rectangle a Queue or ClassSwitch, a rounded box a Delay or Router, a hexagon a Fork or Join, a cylinder a Cache. A station label picks up its server count, (k=N) for a multiserver or (IS) for an infinite server, and the trailing comment line lists every class with its population, (open) or (N=...). Labels are always quoted, since a bare Mermaid label may not hold parentheses.

Routing is declared one hop at a time with P.set(from_class, to_class, from_node, to_node, prob), or a whole class at a time by assigning a node-by-node matrix to P[jobclass, jobclass]. Indexing a routing matrix by node, as in P[jobclass, src, dst] = p, is not supported and raises ValueError. For a plain chain, Network.serial_routing([...]) builds the matrix for you.

Example Gallery

Browse and retrieve LINE's built-in examples:

Example categories include: open queues, closed networks, layered QN, fork-join, caches, and random environments.

Solver Guide

SolverBest For
MVAProduct-form networks with exponential service (fast, exact)
NCClosed networks needing exact normalizing-constant analysis
CTMCSmall state-space models; supports non-product-form features
MAMPhase-type / MAP service distributions (matrix analytic)
SSAStochastic simulation; works for any supported model
FLDFluid / ODE approximation for transient and large models
LDESLINE's own discrete-event engine; the widest feature set in simulation
JMTSimulation through Java Modelling Tools (needs a Java runtime)
LNLayered queueing networks, with the per-layer engine named as ln.mva, ln.nc or ln.comom
LQNSLayered models through the external lqns/lqsim binaries
QNSThe qnsolver binary
ENVModels in a random environment; stage_solver names the engine run at each stage
UQUncertainty quantification and the interval analysis; uq_solver names the engine run at each design point
AGAgent-based (RCAT) analysis
BABounding analysis: asymptotic, balanced-job and LP bounds
AUTOPicks for you, as do the names line, sim, exact, fast and accurate

Not every tool reaches every solver. solve_model_file, sweep_model, analyze_queue, analyze_network and sweep_parameter accept every name in the table, plus a solver.method: the analyze tools hand their model to the same dispatch the document surface uses. find_solver reports which of them can analyze the model in hand. compare_solvers takes the six in-process network solvers only: MVA, CTMC, NC, SSA, FLD and MAM.

Output Formats

Most tools accept a format parameter:

It is taken by analyze_queue, analyze_network, sweep_parameter, the three builders, sweep_model, convert_model, find_solver, list_solvers, list_gallery, check_install, get_version and solve_line_model. Five tools have no format at all and answer with the one rendering they have: compare_solvers and visualize_model, plus list_examples, get_example_code and clear_session.

solve_model_file is the exception that renders differently: its argument is output, which is readable (the default), json, csv, or pickle and mat written to output_file.

A run that never reaches a result answers in the requested format too: in json mode a rejected input, an unstable system or a solver failure comes back as {"error": "..."} rather than as plain text, so a caller that parses the reply reads the reason instead of a parse failure.

JSON Output Example

{
  "results": [
    {
      "Station": "Source",
      "JobClass": "Class1",
      "QLen": 0.0,
      "Util": 0.0,
      "RespT": 0.0,
      "ResidT": 0.0,
      "ArvR": 0.0,
      "Tput": 2.0
    },
    {
      "Station": "Queue",
      "JobClass": "Class1",
      "QLen": 0.6666666666666666,
      "Util": 0.4,
      "RespT": 0.3333333333333333,
      "ResidT": 0.3333333333333333,
      "ArvR": 2.0,
      "Tput": 2.0
    }
  ],
  "metadata": {
    "model": "M/M/1",
    "arrival_rate": 2.0,
    "service_rate": 5.0,
    "servers": 1,
    "utilization": 0.4,
    "solver": "MVA"
  }
}