yarrowdalegraph
yarrowdalegraph
Graph-based fraud propagation scoring for Elmsworth Diagnostics (Project
Yarrowdale). It runs as an MCP server that exposes one tool, score_graph, over
JSON-RPC 2.0 on stdio.
What it does
Clinical-diagnostics fraud is rarely visible in a single entity. A colluding provider, the billing accounts it controls, the devices those accounts log in from, and downstream referrers form a connected structure. Yarrowdale models that structure as a graph and diffuses fraud risk across it.
The pipeline is:
Build an entity graph: nodes are providers, patients, accounts, devices and claims; edges are observed relationships with a strength weight. Analysts can attach a
seedin[0,1]to any node (a confirmed-fraud entity is1).Obtain a per-node prior risk from a graph-ML model via the
RiskPriorProviderinterface.Propagate those priors across the graph with a personalised-PageRank-style diffusion until convergence, producing a final risk score per node.
The hard constraint: deterministic fallback
Scoring must keep working when the model is unavailable. That is not handled by
a try/catch bolted on at the edge — it drives the architecture:
The model is reached only through
RiskPriorProvider(src/providers/base.ts). Its sole job is to turn a graph into prior scores.FraudScorer(src/scorer.ts) consults the provider behind three guards. Any of them routes to the deterministic path:available()returns false (or throws),priors()throws,priors()returns a map that fails validation (wrong size / out of range).
The fallback (
src/fallback.ts) computes priors from graph structure alone — analyst seeds plus weighted neighbour evidence. No network, no model, fully deterministic.Both paths then run the same propagation algorithm, so a fallback score differs from a model score only in its priors, never in the method. That keeps results explainable to a reviewer.
Every degradation decision is recorded through the
auditcallback, and the result carriespriorSource('model'|'fallback') andfallbackReason.
The test suite runs entirely offline against StubProvider
(src/providers/stub.ts), which also has flags (down, throwOnPriors,
returnIncomplete) to exercise each degradation branch.
Layout
src/
types.ts domain model (nodes, edges, params, results)
graph.ts validation + row-normalised transition matrix
propagation.ts core diffusion algorithm
fallback.ts deterministic structural priors
scorer.ts orchestration + degradation + audit
config.ts environment-driven configuration
logger.ts structured JSON logging to stderr
validation.ts untrusted-input parsing + resource limits
server.ts MCP JSON-RPC request handling
main.ts stdio entry point
index.ts public exports
providers/
base.ts RiskPriorProvider interface
stub.ts deterministic offline provider
forced-fallback.ts operator pin to the offline path
tests/ node:test suitesArchitecture
Request flow for a score_graph call:
stdin (JSON-RPC line)
-> main.ts framing, ordering, fatal-error guard
-> server.ts JSON-RPC dispatch, error codes, timing, audit->log
-> validation.ts untrusted JSON -> FraudGraph, resource limits
-> scorer.ts model-or-fallback decision (the degradation point)
-> providers/ model priors, or ...
-> fallback.ts deterministic structural priors
-> propagation.ts diffusion shared by both paths
-> stdout (JSON-RPC line) logs -> stderrThe decisions that had a real alternative are recorded in
docs/adr/: the provider-interface fallback boundary, the shared
propagation path, undirected iterate-don't-invert diffusion, the zero-dependency
MCP transport, and fail-fast configuration.
Requirements
Node 22 or newer. Node runs the TypeScript sources directly, so there is no
build step and no dist/.
Install and test
npm ci
npm testnpm test runs the built-in node --test runner over tests/**/*.test.ts.
Optional type check (requires devDependencies installed):
npm run typecheckRunning the server
npm startThis starts the MCP server on stdio. It reads one JSON-RPC message per line on stdin and writes one response per line on stdout; all logs go to stderr so they never corrupt the protocol stream. A minimal session:
printf '%s\n%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"score_graph","arguments":{"graph":{"nodes":[{"id":"p1","kind":"provider","seed":1},{"id":"acc","kind":"account"}],"edges":[{"source":"p1","target":"acc","weight":2}]}}}}' \
| npm start --silentThe score_graph result is JSON text containing priorSource
(model | fallback), fallbackReason, converged, iterations,
elapsedMs, and a scores map.
Configuration
All configuration is read from the environment at startup. A malformed value is a fatal startup error (exit code 78), never silently ignored.
Variable | Default | Meaning |
|
| Diffusion mixing, strictly in |
|
| Convergence threshold |
|
| Iteration cap |
|
| Reject graphs with more nodes |
|
| Reject graphs with more edges |
|
| Pin scoring to the offline path |
|
|
|
YARROWDALE_FORCE_FALLBACK=true is the operator switch for a model incident: it
routes every request through the deterministic fallback without a redeploy.
Library usage
import { FraudScorer, StubProvider } from './src/index.ts';
const scorer = new FraudScorer(new StubProvider(), {
audit: (e) => console.error(`[audit] ${e.event}: ${e.detail}`),
});
const result = await scorer.score({
nodes: [
{ id: 'p1', kind: 'provider', seed: 1 }, // confirmed fraud
{ id: 'acc', kind: 'account' },
{ id: 'dev', kind: 'device' },
],
edges: [
{ source: 'p1', target: 'acc', weight: 2, relation: 'billing' },
{ source: 'acc', target: 'dev', weight: 1, relation: 'login' },
],
});
console.log(result.priorSource); // 'model' or 'fallback'
console.log(result.scores.get('acc'));Algorithm notes
Propagation solves the fixed point
s = alpha * W s + (1 - alpha) * pwhere W is the row-normalised undirected adjacency and p the prior vector.
For alpha in (0,1) the iteration is a contraction and converges to the
unique solution s = (1 - alpha)(I - alpha W)^{-1} p. Edges are treated as
undirected because collusion evidence is symmetric. We iterate rather than
invert: the graphs are sparse and a few hundred sweeps are cheaper than an
O(n^3) solve. Default alpha = 0.85, tolerance 1e-9, cap 200 iterations.
Known limitations
The learned
RiskPriorProvider(graph neural network) is not yet shipped; only the interface and the deterministic stub are.main.tswires in the stub and will select the model provider from config once it exists. See the TODO insrc/providers/base.ts.The stdio transport handles one message per line and serialises request handling. That is enough for a single MCP client; JSON-RPC batch arrays and concurrent in-flight scoring are not implemented.
Scoring is synchronous and in-memory. A graph near the configured node/edge limits is scored in one event-loop-blocking pass; there is no chunking or backpressure. The limits exist to keep that pass bounded, not to stream.
Propagation treats every relationship type the same once weighted. Distinct edge semantics (a shared device vs. a referral) are captured only through the caller-supplied
weight, not through per-relation propagation rules.
Elmsworth Diagnostics is an illustrative client; this repository is a self-directed reference implementation built to work end to end.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/J-X0/elmsworth-diagnostics-graph-ml-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server