Design - visubramaniam/vsp360-mcp GitHub Wiki
This page describes how vsp360-mcp is built and why. It's for people who want to change the code, review it, or borrow its approach. How It Works is the shorter, user-facing tour; Safety and Security and SECURITY.md cover the security model and review findings in depth. This page is current for 0.1.23 (October 2026); the code is the final word where they differ.
Contents
- Purpose, goals and non-goals
- Context
- Architecture overview
- Trust model
- Design decisions
- Components
- Request lifecycles
- Data files
- Configuration model
- Safety controls and where they live
- Build, test, evaluation and release
- Performance and resource use
- Extension points
- Known limitations
- Future direction
- Glossary
vsp360-mcp lets an MCP client, usually Claude, work with a Hitachi VSP 360 through its REST API in plain language. It is a community project, not a Hitachi Vantara product.
Goals
- Complete coverage. Every operation in the VSP 360 1.4 Platform OpenAPI spec is reachable: 264 operations in 35 API areas.
- Cheap to carry in a conversation. The server must not cost hundreds of kilobytes of tool definitions per conversation, however large the API.
- Safe with a model that can be steered. Text the model reads from VSP 360 (alert descriptions, device names, CSV validation errors) may try to make it do something else. The server limits what the model can do on its own, and the defaults are the safe ones.
-
Good at finding the right operation. An operator's wording ("volumes with no host path") and the spec's wording (
raidLdev,host group) differ. Search has to bridge that, say how sure it is, and be measured. - Easy to install for storage administrators. Many run Windows machines without Node.js, behind corporate proxies, with private CAs. Every package must work there.
- Easy to keep current. A new VSP 360 release should mean a new spec file, not a code rewrite.
Non-goals
- Serving many users from one server with their own VSP 360 identities. The server runs locally, as one service account, over stdio. Remote transport and OAuth are discussed under Future direction.
- Hiding the API. The server is a faithful, safe proxy to the REST API, not a higher-level abstraction over storage concepts. The two skills in the Claude Code plugin add workflow-level guidance on top.
- Replacing VSP 360's own permission model. The service account's VSP 360 roles still decide what it may do; the server only narrows that further.
VSP 360 is Hitachi Vantara's management platform for its storage fleet: block, file, object and software-defined storage, with device registry, alerts, logs, metrics (a Prometheus-style API), an Analytics query API (MQL), data protection, orchestration workflows, upgrades, identity, certificates and billing reports. Its REST API is documented in an OpenAPI 3 document of about 1.9 MB. Authentication is OAuth 2 client credentials against a token endpoint on the same host.
MCP (Model Context Protocol) is the open protocol through which AI clients call tools. A server lists tools with JSON Schema inputs; a client puts those definitions in the model's context, and the model calls them. Clients differ in how many tools they accept and how they present them. vsp360-mcp targets Claude Desktop, Claude Code and claude.ai, and works with any stdio MCP client.
The users are storage administrators and the people who support them. They ask questions such as "which pools are over 80% full", "what alerts fired overnight", "download the diagnostic bundle for job X", and, with writes enabled, "register this array" or "expand that volume".
flowchart LR
subgraph Client["MCP client (Claude Desktop, Claude Code, ...)"]
M[Model]
end
subgraph Server["vsp360-mcp process (stdio)"]
IX["index.ts<br/>handlers and gates"]
SP["spec.ts<br/>Operation catalogue"]
SR["search.ts<br/>BM25 index, confidence"]
DM["decision.ts<br/>learned hint"]
DL["decisionlog.ts<br/>local JSONL log"]
AF["analyticsfields.ts<br/>MQL reference"]
TL["tools.ts<br/>tool definitions"]
CL["client.ts<br/>HTTP client"]
end
subgraph Files["Bundled data (spec/)"]
Y[(OpenAPI YAML<br/>+ build-time cache)]
EX[(examples.json)]
MQ[(analytics-fields.json)]
DMJ[(decision-model.json)]
end
V[(VSP 360<br/>REST API)]
M <-- "JSON-RPC over stdin/stdout" --> IX
IX --> SP & SR & DM & DL & AF & TL & CL
SP --- Y
SR --- EX
AF --- MQ
DM --- DMJ
CL <-- "HTTPS, bearer token" --> V
The server is one Node.js process, started by the client, that holds the whole operation catalogue in memory and proxies calls to one VSP 360. There is no database, no background work and no network listener. State is a parsed spec, a search index, a cached access token, and an optional append-only log file.
Source layout (src/, about 1,800 lines of TypeScript):
| File | Responsibility |
|---|---|
index.ts |
Startup sequence, the three discovery tools' handlers, direct tools, the permission gates, the MCP server loop |
spec.ts |
Loads the OpenAPI document, builds the Operation model, resolves $refs, holds the read-only and secret allow-lists |
tools.ts |
Builds MCP tool definitions: the three meta-tools and one direct tool per operation |
search.ts |
Tokenisation, synonyms, BM25 index, intent priors, confidence rating, the Analytics hint |
decision.ts |
Features and prediction for the learned "what next" hint |
decisionlog.ts |
The opt-in local decision log and collection mode |
analyticsfields.ts |
The MQL field reference added to analyticsRunQuery
|
client.ts |
Token handling, URL building, request sending with redirect policy, pagination, size limits, downloads, uploads, file-path rules |
config.ts |
Server settings from the environment, bundled-file paths, JSON loading with clear errors |
Runtime dependencies are three: @modelcontextprotocol/sdk (protocol), undici (HTTP, with a custom Agent for TLS options) and yaml (loaded only when the spec cache can't be used).
Every design decision below follows from three assumptions, written down in SECURITY.md:
| Party | Trust | Consequence |
|---|---|---|
| The operator who installs the server and sets its environment | Trusted | Settings decide what the server may do. The server never second-guesses VSP360_ALLOW_WRITES=true. |
| The model calling the tools | Semi-trusted | It acts for the user, but text it reads can steer it. So the server, not the model, enforces read-only mode, secret gating, file-path confinement and the network boundary, and those checks don't depend on anything the model says. |
| VSP 360 responses | Untrusted input | Response bodies are size-limited, file names from Content-Disposition are reduced to a bare name, pagination links are re-homed, redirects are confined, and response content never changes server behaviour. |
The practical test for any new feature is: if injected text in an alert convinced the model to call this with the worst possible arguments, what happens? The answer has to be "nothing the operator didn't allow".
Each decision gives the choice, the reasons, and what was rejected.
The spec is parsed when the server starts, and the Operation model built from it drives tool definitions, parameter validation, URL building, body handling and the describe output. Nothing is generated at build time except a pre-parsed copy of the same document.
Why: a new VSP 360 release is a new spec file (VSP360_SPEC), with no regeneration step. The same code path serves a newer or older spec. Tool definitions can't drift from the API.
Rejected: code generation (Speakeasy-style). It gives typed clients, but every spec change becomes a code change and a release, and it encourages one tool per operation.
The server always exposes vsp360_list_operations, vsp360_describe_operation and vsp360_call_operation, about 3 KB of definitions. VSP360_TOOLSETS adds one direct tool per operation for chosen API areas; all adds all 264, about 433 KB.
Why: loading 264 schemas into every conversation costs tokens on every turn and makes tool selection worse, not better. Discovery on demand keeps the per-conversation cost constant however many operations the spec has. Direct tools remain for the areas someone uses constantly, where skipping the search turn is worth the context.
Costs accepted: one or two extra turns per new task (search, sometimes describe). Two mitigations reduce that: HIGH-confidence searches include the top result's parameters so the describe turn can usually be skipped, and responses are compact JSON.
Rejected: exposing everything and relying on the client's own tool search. Some clients can defer-load tools now, but not all, and the server would lose control of ranking.
GET operations are reads. A POST is a read only if it's in READ_ONLY_POSTS (six operations: analytics query, inventory filter, authorization check, user capabilities, CSV validation, upgrade validation). PUT, PATCH and DELETE are always writes. READ_ONLY_PARAMS narrows one further: analyticsRunQuery is a read only with action=query, because its other actions can abort queries.
Why: HTTP method alone is wrong for this API, which uses POST for some queries. An explicit list, reviewed by a person, is safer than a heuristic. It also makes the write count testable (tests/unit/server.test.mjs asserts all 125 writes are refused).
A judgement call: vsp360VerifyDirectory changes nothing in VSP 360, but it makes VSP 360 open an LDAP connection to any host with any bind credentials, so it's treated as a write (security review finding S4).
Three operations return a live credential: vsp360GetServiceAccountSecret (a read) and the two Block SSO operations vsp360RevealBlockSSOSetup and vsp360RotateBlockSSOSecret (writes). All three need VSP360_ALLOW_SECRET_READS=true, whatever VSP360_ALLOW_WRITES says, and get no direct tool otherwise.
Why: a secret returned to the model becomes part of the conversation, which may be logged, shared or summarised elsewhere. That's a different risk from changing state, so it has its own switch.
The model chooses save_to paths and upload paths. Downloads and save_to stay inside VSP360_DOWNLOAD_DIR, never overwrite, and never enter a hidden folder; the folder check compares real paths, so a symlink inside the folder can't redirect a write. Uploads refuse hidden paths and, when VSP360_UPLOAD_DIR is set, anything outside it, again through symlinks. In read-only mode, any uploading operation needs VSP360_UPLOAD_DIR.
Why: the filesystem servers in the MCP ecosystem learned these rules through incidents. The upload rule in read-only mode exists because the CSV validation operation, a read, would otherwise let a steered model send ~/.ssh/id_rsa or an MCP client's config file to VSP 360, which echoes rows back in its validation errors (finding S1).
The client secret goes only to the token endpoint under VSP360_URL, and the token request never follows a redirect. API requests follow a redirect only within VSP360_URL's origin, and only for GETs or 307/308. Pagination next links are re-homed onto VSP360_URL. http:// needs VSP360_ALLOW_HTTP=true. Path parameters can't be ., .. or contain slashes, so URL normalisation can't reach a different endpoint than the one the gates checked.
Why: with VSP360_INSECURE_TLS=true, which lab systems use, a man in the middle could otherwise redirect the token request and collect the secret. Re-homing pagination links handles a real quirk of the Data Protection service, whose links omit its gateway prefix, and at the same time stops a response from pointing the token at another host.
Search is BM25 over each operation's ID, summary, tag, path, description and a set of example questions, with field weights, camelCase splitting, light stemming, a storage-vocabulary synonym table, verb-intent priors and a preference for collection endpoints on list-style requests. Each result set carries a HIGH, MEDIUM or LOW confidence computed from two signals: how far the top hit leads the runner-up, and how much of the user's own wording it covers. A regression suite of labelled requests gates every change.
Why lexical and not embeddings: it runs in-process with no model, no network and no startup cost, it's deterministic and explainable (each hit shows which terms matched), and on this catalogue the labelled scores were competitive once example questions were added. The example questions are where domain knowledge lives: 504 questions across all 264 operations, written in operator language.
Why a confidence rating: the model needs to know when to call straight away and when to compare candidates. The rating turns two numbers into advice, and the thresholds were tuned against the labelled sets.
A small multinomial logistic regression (13 features, 4 classes: call, compare, analytics, not found) trained on 296 logged requests can suggest the next step after a search. It's off by default, and when on it adds a line of advice to the result without changing the order.
Why: keeping ranking deterministic keeps it explainable and testable. The model exists to test whether learned advice beats the hand-tuned confidence thresholds, and it's wired for A/B comparison (VSP360_DECISION_HINT=ab) so that question can be answered with data rather than opinion.
npm run build writes dist/openapi-cache.json: the parsed document plus the SHA-256 of the YAML it came from. At startup, if the YAML's hash matches, the JSON is used and the yaml package is never loaded. Otherwise the YAML is parsed as before.
Why: a stdio server starts every time a client does. Parsing 1.9 MB of YAML took about 250 ms and briefly 250 MB; the cache brought time-to-ready from about 540 ms to 270 ms and peak memory from 290 MB to 115 MB. The hash check means replacing the YAML still takes effect at once.
The server speaks MCP over stdin and stdout. Settings come from VSP360_* environment variables, which every client can set per server entry. The HTTP client is built on first use, but its settings are validated at startup so a mistake is logged immediately.
Why: the audience runs the server on their own machine against one on-prem system, which is what stdio is for. Lazy construction lets the server start and list tools even when VSP 360 is unreachable, which clients expect; validating early still surfaces a bad URL or missing secret in the client's log at startup rather than on the first call.
npm (npx vsp360-mcp or a global install), a Claude Desktop extension (.mcpb), standalone archives with a bundled Node.js runtime for six platforms, a multi-arch container image, and a Claude Code plugin that installs the server from a lockfile.
Why: storage administrators often can't install Node.js or Docker. The standalone archive, started through runtime\node.exe dist\index.js or a launcher script, is the one that works on a locked-down Windows machine. The plugin's lockfile install exists because Anthropic's directory requires pinned, hash-verified dependencies for its Verified badge.
GitHub Actions are pinned by commit SHA, the container base image by digest, the extension packer (@anthropic-ai/mcpb) by exact version through npx rather than as a dependency, and the Node.js runtimes in the standalone packages are checked against SHASUMS256.txt whose signature is verified with the Node.js release keys, themselves pinned to a commit. Dependabot proposes updates weekly.
Why: the packages ship a runtime and run as the operator with their credentials. The build inputs deserve the same care as the code.
loadServerConfig() reads the server-side settings (spec path, write and secret switches, toolsets, data-file paths, decision-log options). client.ts's loadConfig() reads the HTTP-side ones (URL, credentials, TLS, timeouts, folders). Both take the environment as an argument so tests can pass their own.
dropUnfilledSettings() runs first at startup. Claude Desktop passes an optional extension setting left empty as the literal text ${user_config.ca_cert}; before 0.1.22 that was read as a file path and every call failed on Windows with ENOENT ... C:\Windows\system32\${user_config.ca_cert}. Any VSP360_* variable still holding such a placeholder is deleted.
loadJson and loadOptionalJson name the file and the problem in their errors, and orExit in index.ts turns a bad data file into one clear line on stderr and exit code 1 rather than a stack trace.
Spec loads the document (JSON directly, YAML through the cache or the parser) and indexes every operation under the five HTTP methods, skipping the token endpoint, which the server uses itself and never exposes. It releases the parsed document after indexing to free about 100 MB of heap.
Each Operation holds: ID, method, path, first tag, summary, description, parameters (path, query, header; path parameters are always required), the request body's preferred content type (JSON if offered) and schema, success content types, and the classification flags readOnly, destructive (DELETE), secret, and readOnlyParams.
$ref resolution inlines references into self-contained JSON Schema, which MCP clients need. Recursive schemas are cut at the point of recursion with a note, depth is capped (12 for inputs, 6 for response schemas), and example, xml, discriminator and nullable are dropped or folded (nullable becomes a ["type", "null"] union) to keep definitions small and valid.
toolParamName rewrites parameter names that MCP clients reject: the metrics API's Prometheus-style match[] becomes match, and the client maps it back before calling the API.
Tags come from the document's tags list, with the first sentence of each description and a count of operations, plus any tag used by an operation but not declared.
metaTools() builds the three discovery tools. Their descriptions change with the settings: read-only mode says that [write] operations are blocked, the decision log adds an optional request argument to searches, and collection mode tells the model to search before every call. Annotations follow the MCP spec: the search and describe tools are readOnlyHint, and the call tool is readOnlyHint in read-only mode and destructiveHint otherwise.
directTool() builds one tool per operation: parameters become top-level properties under their tool-safe names, the body becomes a body property, multipart uploads get a files property for file paths and keep the other form fields in body, and GETs get all_pages, max_pages and save_to. Each direct tool carries readOnlyHint, destructiveHint, idempotentHint and openWorldHint. Descriptions are capped at 2,000 characters and names at 64, the MCP limit.
Tokenisation. Text is split on camelCase (keeping acronyms such as WWNs together), lower-cased, split on non-alphanumerics, stripped of stopwords (including "vsp", "360", "hitachi", "api") and lightly stemmed (plurals, -ing, -ed, -ies).
Index. One document per operation, built as a term-frequency map with field weights applied by repetition (BM25F-lite): summary 4, operationId 3, example questions 3, tag 2, path 2, description 0.5 (first 1,500 characters). BM25 constants are k1 = 1.2, b = 0.75.
Query expansion. The user's words are tokenised, then a synonym table maps storage vocabulary to spec vocabulary: volume โ ldev, server โ host, snapshot โ copies, latency โ response and metric, LDAP โ directory, ESXi and vCenter โ hypervisor, and so on. Expanded terms score at half weight so the user's own words dominate.
Priors. A verb reveals intent: "list", "show", "which" favour reads (ร1.25, writes ร0.7); "create", "delete", "register" favour writes (ร1.2, GETs ร0.75). Unless the request names a specific object or wants a change, endpoints with a path parameter are discounted (ร0.8) so list-style requests prefer collections.
Confidence. With lead = top score รท second score and coverage = share of the user's own terms the top hit matched:
| Rating | Rule | Advice given |
|---|---|---|
| HIGH | lead โฅ 1.3 and coverage โฅ 0.6 | Use the top result; its parameters are included |
| MEDIUM | (lead โฅ 1.1 and coverage โฅ 0.5) or coverage โฅ 0.75 | Check the top result's description before calling |
| LOW | otherwise | Describe the top two or three and compare |
Analytics hint. Requests that look like performance, history or topology questions (IOPS, latency, WWN, zone, trend, over time...) get a note that analyticsRunQuery usually answers them, unless the result is already HIGH. The hint's presence is also a feature of the decision model, so one function (hintFor) serves the server, the evaluation and training.
The decision log is off unless VSP360_DECISION_LOG names a file. It appends one JSON object per event: each search (query, the user's request if the model passed it, confidence signals, ranked IDs and scores, the model's suggestion if any), each describe, and each call (operation, via meta or direct tool, argument names, HTTP status, duration, whether a gate blocked it). Describes and calls are linked to the latest search if it was within VSP360_DECISION_LINK_SECONDS (120), with the operation's rank in that search. Never logged: argument values, bodies, headers, tokens, credentials. A failed write to the log is reported on stderr and never fails a tool call.
Collection mode (VSP360_DECISION_COLLECT=true, needs the log) makes every question produce a labelled search: searches without request are refused, and calls are refused unless the operation appeared in a search within VSP360_DECISION_COLLECT_SECONDS (600). It exists for data-collection runs, not everyday use.
The decision model predicts one of call, compare, analytics, not_found from 13 features: log of the lead (capped at 3), coverage, read and write intent, whether the Analytics hint fired, whether analyticsRunQuery is first or in the top three, log of the top score, log of the query length, whether the query names an identifier (pool-01, ALT-8832, 10:2A), whether the top two share a tag, whether the top hit is a by-ID endpoint, and read intent with a write on top. Standardised features feed a softmax over per-class weights. tests/train-decision.mjs trains it from decision-report rows and writes spec/decision-model.json; the shipped model was trained on 296 requests with L2 regularisation ฮป = 0.03.
VSP360_DECISION_HINT is false (default), true (show the suggestion) or ab (show it in a random half of server sessions). Whenever the log is on, the suggestion is predicted and logged whether or not it's shown, so the report can compare sessions. The not_found wording was changed after A/B runs showed "the API may not cover this" made the model give up too early.
The spec documents analyticsRunQuery but not what can be queried. spec/analytics-fields.json holds 56 resource types with their configuration attributes, metrics and relations, marked as coming from Hitachi's guide or observed on a live system. Describing analyticsRunQuery adds a compact reference: usage notes (call shape, filter and relation syntax, time formats, the one-hour window limit, derived values, what a 403 means), full attribute lists for 18 core types, and names and relations for the rest. Describing it with resourceType returns one type in full. Without this the model guesses field names and wastes calls on empty results.
Agent. One undici.Agent per client, with rejectUnauthorized from VSP360_INSECURE_TLS, an optional CA from VSP360_CA_CERT, and header and body timeouts off, because uploads can be 20 GB. Per-request deadlines come from AbortSignal.timeout(VSP360_TIMEOUT_MS) instead, and multipart uploads have none.
Tokens. getToken() returns the cached token until a minute before expiry (or half its life for very short tokens). Concurrent first calls share one in-flight token request. A 401 clears the token and retries the request once.
URL building. Direct-tool parameter aliases are mapped back to spec names. Unknown parameters are an error naming the valid ones; missing required ones are an error too. Path values are validated (no ., .., / or \) and URL-encoded. Arrays repeat the query key; objects are JSON-encoded. Header parameters go in the request headers. VSP360_PATH_REWRITES (default: re-home /api/v1/protection under /dataprotection) is applied to the path and to server-supplied links.
Sending. redirect: "manual" on every request. A redirect to another origin fails the call; one within the origin is followed for GETs and 307/308 responses, up to 5 hops; any other same-origin redirect is returned as is.
Bodies. JSON by default, form-encoded when the spec says so, or a FormData for multipart with files opened as blobs (streamed from disk) after the upload-path checks. Non-file form fields come from body.
Pagination. With all_pages, the first page is parsed as a JSON array. If the Link header gives both next and last and they differ only in offset, every page URL is computed up front and fetched four at a time, in order; otherwise next links are followed one by one. max_pages defaults to 20 and is clamped to 1โ200. A non-array page (a gateway error) or hitting the byte budget stops the walk with a note and keeps the pages before it. Results concatenate into one array, with a label of items and pages.
Size limits. readBody reads a response in chunks against a budget, VSP360_MAX_RESPONSE_BYTES (64 MiB). One budget covers all pages of an all_pages call. A single oversized response fails with a suggestion to use save_to; VSP360_MAX_RESPONSE_CHARS (60,000) then truncates the text shown to the model, with a note on narrowing the request.
Rendering. Binary responses (anything whose content type isn't JSON, text, XML, PEM, YAML or CSV) and any save_to response are streamed to disk with a counted byte total; the file name comes from Content-Disposition reduced to a bare name, or is generated from the operation and time, made unique with a numeric suffix. Text responses are returned with the status line and selected headers (content-range, link, location, preference-applied), and JSON is re-serialised compact, which saves about a quarter of the tokens.
File rules. saveTarget resolves the requested path, computes its real path through the deepest existing ancestor, checks it's inside the real download folder (unless VSP360_SAVE_ANYWHERE), refuses hidden segments, refuses existing files, and creates parent folders. uploadPath requires the file to exist, resolves it through symlinks, and applies the upload-folder or hidden-path rule.
Startup, in order: drop unfilled placeholders โ read server settings โ load the spec (exit on failure) โ load example questions and build the search index โ load the MQL reference โ load the decision model and decide whether to show the hint โ open the decision log โ validate the HTTP settings (warn, don't exit) โ build the direct-tool set from VSP360_TOOLSETS, skipping anything a gate would block โ register the two MCP handlers โ connect stdio โ print one ready: line on stderr with operation count, direct-tool count, mode and which optional features are on.
Gates. blockedReason(op) is the single place that says why an operation can't be called under the current settings: a write in read-only mode, an upload in read-only mode without an upload folder, or a secret without the secret switch. It's used to refuse calls, to leave blocked operations out of the direct tools, to label operations in search and describe output, and to decide whether a HIGH-confidence search includes parameters. blockedArgs(op, params) adds the per-argument rule for readOnlyParams.
Handlers. listOperations handles the three shapes of vsp360_list_operations (areas, one area, a query), runs the search, writes the log, and formats the result with confidence, advice, the hint, the suggestion and, for HIGH confidence, a one-line-per-parameter summary of the top operation. describeOperation returns the operation's full definition as compact JSON, with the access label, the response schema and, for analyticsRunQuery, the MQL reference. runOperation applies collection mode, logs the call, applies the gates, and hands the call to the client; direct tools reach it after their flat arguments are split into params, body, files and the call options.
Tool arguments aren't schema-validated by the SDK, so the handlers coerce the scalars they read (str, int) and the client validates parameters against the spec. Any thrown error becomes an isError result with the message and its cause; the server doesn't crash on a bad call.
tools/list returns the three meta-tools plus the direct tools for the configured toolsets. The list is fixed for the life of the process; the server doesn't use list_changed.
sequenceDiagram
participant U as User
participant M as Model
participant S as vsp360-mcp
participant V as VSP 360
U->>M: "which pools are over 80% full?"
M->>S: vsp360_list_operations(query)
S-->>M: Confidence + ranked operations (+ parameters if HIGH, + Analytics hint)
opt confidence not HIGH, or body fields nested
M->>S: vsp360_describe_operation(operationId)
S-->>M: parameters, body schema, response schema, access label
end
M->>S: vsp360_call_operation(operationId, params, body)
S->>S: collection check, decision log, gates (read-only, upload, secret, args)
S->>V: POST /api/v1/vsp360/auth/token (first call only, or after 401)
V-->>S: access token
S->>V: request with bearer token
V-->>S: response (JSON, pages, or a file)
S-->>M: status line + compact JSON, or the saved file's path
M-->>U: answer
- Collection mode (if on): refuse unless the operation appeared in a recent search.
- Decision log: record the call's start with the operation, route and argument names.
-
Gates:
blockedReason, thenblockedArgs. A refusal is a normal error result with the setting that would allow it. - URL: map aliases, validate parameters, build path and query, apply rewrites.
- Body: build JSON, form or multipart; multipart file paths pass the upload rules first.
- Send: attach the token, apply the timeout, handle redirects within the origin, retry once on 401.
-
Render: stream to a file, or read within the byte budget and return text; with
all_pages, walk the pages first. - Log the outcome: status, success, duration.
A diagnostics bundle download is a GET whose response is application/zip: the body is piped to a wx-opened file in the download folder and the result says how many bytes went where. An upgrade package upload is a multipart POST whose files.file is opened as a blob and streamed; nothing is buffered, and the request has no timeout.
| File | What it is | How it's maintained |
|---|---|---|
spec/Platform-vsp360-openapi.yaml |
The VSP 360 1.4 Platform OpenAPI document (1.9 MB) | Replaced when Hitachi Vantara publishes a new one; VSP360_SPEC overrides it at runtime |
dist/openapi-cache.json |
The parsed document with the YAML's SHA-256 (1.4 MB) | Written by npm run build (scripts/cache-spec.mjs); not in git |
spec/examples.json |
504 example questions across all 264 operations | Written by hand in operator language; tests/search-eval.mjs reports operations without examples and checks every ID exists |
spec/analytics-fields.json |
56 MQL resource types with attributes, metrics, relations and provenance | From Hitachi's Analytics guide plus types observed on a live system (marked observed) |
spec/decision-model.json |
The learned hint's features, classes, standardisation and weights | Written by npm run train-decision -- --write from decision-report rows |
tests/user-queries.md |
119 labelled user questions, split into train and held-out sets | Written by hand; the held-out split is never used for tuning |
tests/cases.mjs |
The development request sets (tuning, two held-out sets) | Written by hand |
None of these contain data from a real environment; placeholders such as ARRAY-A and 410001 are used (security review finding S2).
All settings are environment variables prefixed VSP360_, read once at startup. Configuration lists them for users; the groups are:
| Group | Variables | Read by |
|---|---|---|
| Connection |
URL (required, https://), CLIENT_ID, CLIENT_SECRET (required), SCOPE, CA_CERT, INSECURE_TLS, ALLOW_HTTP, TIMEOUT_MS, PATH_REWRITES
|
client.ts |
| Permissions |
ALLOW_WRITES, ALLOW_SECRET_READS, TOOLSETS
|
config.ts, index.ts
|
| Files |
DOWNLOAD_DIR, SAVE_ANYWHERE, UPLOAD_DIR
|
client.ts (and index.ts for the read-only upload gate) |
| Responses |
MAX_RESPONSE_CHARS, MAX_RESPONSE_BYTES
|
client.ts |
| Data files |
SPEC, EXAMPLES, ANALYTICS_FIELDS, DECISION_MODEL
|
config.ts |
| Search improvement |
DECISION_LOG, DECISION_LINK_SECONDS, DECISION_COLLECT, DECISION_COLLECT_SECONDS, DECISION_HINT
|
config.ts, index.ts
|
Boolean settings are the string true; anything else is off. Empty strings mean "unset", which matters because clients often pass empty values for optional fields.
The Claude Desktop extension (packaging/mcpb/manifest.json) and the Claude Code plugin (plugin/.claude-plugin/plugin.json) map a smaller set of user-facing settings onto these variables: URL, client ID, secret (marked sensitive), direct toolsets, allow writes, CA certificate, skip TLS verification, download folder, upload folder. Everything else keeps its default.
| Control | Default | Code | Test |
|---|---|---|---|
| Writes refused | on |
READ_ONLY_POSTS in spec.ts; blockedReason in index.ts
|
read-only mode refuses every write operation (server.test.mjs) |
analyticsRunQuery only with action=query
|
on |
READ_ONLY_PARAMS; blockedArgs
|
read-only mode allows analyticsRunQuery only with action=query |
| Secret-returning operations refused | on |
SECRETS; blockedReason
|
the secret read is refused..., the SSO operations... also need VSP360_ALLOW_SECRET_READS
|
| Read-only uploads need an upload folder | on | blockedReason |
read-only mode refuses uploads unless VSP360_UPLOAD_DIR is set |
| Uploads: hidden paths, folder, symlinks | on |
uploadPath in client.ts
|
uploads refuse hidden paths... (client.test.mjs) |
save_to: folder, symlinks, hidden, no overwrite |
on |
saveTarget, realTarget, hasHidden
|
save_to is confined..., save_to can't leave the download folder through a symlink...
|
| Token request never redirected | always | fetchToken |
the token request never follows a redirect |
| API redirects within origin only | always | send |
API redirects are followed within VSP360_URL only |
| Pagination links re-homed | always | followable |
all_pages follows next links... |
./../slash path values refused |
always | buildUrl |
. and .. and slashes are refused as path parameter values |
Response byte budget; max_pages cap |
64 MiB; 200 |
readBody, allPages
|
responses over VSP360_MAX_RESPONSE_BYTES are refused..., max_pages is capped at 200
|
https:// required |
on | loadConfig |
an http:// VSP360_URL is refused unless VSP360_ALLOW_HTTP=true |
Unfilled ${user_config.x} ignored |
always | dropUnfilledSettings |
optional settings left as unfilled ${user_config.x} placeholders are treated as unset |
| Token endpoint never a tool | always |
HIDDEN in spec.ts
|
indexes 264 operations, hides the token endpoint... (spec.test.mjs) |
| No credentials in logs or output | always |
decisionlog.ts (names only), error messages |
Review (SECURITY.md) |
The gates run before any network request, and blocked operations never become direct tools, so a client can't even see a tool it isn't allowed to call.
Build. tsc compiles src/ to dist/ (ES2022, NodeNext modules, strict), then scripts/cache-spec.mjs writes the spec cache. Node.js 20 or later at runtime; tests need 22.
Tests (npm test = build, unit tests, search evaluation):
-
tests/unit/client.test.mjs: the HTTP client against undici'sMockAgent, so nothing reaches the network: tokens, 401 retry, URL building, pagination (sequential and parallel), size limits, redirects,save_toand upload rules, binary downloads. -
tests/unit/spec.test.mjs: the catalogue's counts and classifications,$refhandling, the cache's behaviour when the YAML changes. -
tests/unit/server.test.mjs: the server end to end over stdio, pointed at an address that doesn't exist, so anything that got past the gates fails with a network error rather than reaching a system: every write refused, every gate, direct-tool counts, argument coercion, placeholder handling. -
tests/unit/plugin.test.mjs: the plugin's lockfile rules (registry, integrity hashes, version alignment). -
tests/smoke/package-smoke.mjs: an unpacked standalone package, run with its own Node.js against a mock VSP 360 on loopback: runtime starts, server answersinitializeandtools/listwith the right version through both the runtime and the launcher, a call signs in,save_toand a binary download land in a folder whose path has a space.
Search evaluation (tests/search-eval.mjs) scores the tuning set, two held-out development sets and the held-out user questions (top-1, top-3, MRR, and for user questions whether Analytics and "not available" cases are handled). Each score has a threshold set at the last improvement; a change that lowers any score fails the build. Thresholds are raised when search improves and never lowered to make a change pass. EXAMPLES=0 measures plain BM25 for comparison.
CI (ci.yml, on every pull request and push to main): tests on Node.js 22 and 24; a package job that builds the npm package, the .mcpb and the linux-x64 standalone (which runs the Node.js signature check) and smoke-tests it. CodeQL runs separately.
Release (release.yml, on a v* tag): packages checks the tag against package.json, tests, builds everything and attaches it to the GitHub release created from the changelog section; npm publishes the attached .tgz with trusted publishing (OIDC, provenance attestation, no stored token); docker builds linux/amd64 and linux/arm64 without emulating npm and pushes to ghcr.io and Docker Hub; windows tests the Windows zips on Windows x64 and arm64 machines. Releasing has the procedure.
Packaging (scripts/package.mjs) stages dist/, spec/, the docs and production node_modules (npm ci --omit=dev --ignore-scripts), then builds: the npm tarball; the .mcpb with the manifest's version filled in, packed by npx @anthropic-ai/[email protected]; six standalone archives, each with a Node.js 24 binary verified against the signed SHASUMS256.txt, a launcher (bin/vsp360-mcp or bin\vsp360-mcp.cmd) and the Node.js licence; and SHA256SUMS.
Container image (Dockerfile): three stages on a digest-pinned node:22-alpine. Build and dependency stages run on the build machine's platform because their output is platform-independent JavaScript; only the final stage is per platform. It runs as the node user with VSP360_DOWNLOAD_DIR=/downloads.
| Measure | Value | Notes |
|---|---|---|
| Time to ready | ~270 ms | With the spec cache; ~540 ms when parsing YAML |
| Peak memory at startup | ~115 MB | ~290 MB when parsing YAML; the parsed document is released after indexing |
| Tool definitions per conversation | ~3 KB | Three meta-tools; ~433 KB with VSP360_TOOLSETS=all
|
| Search | < 1 ms | In-memory BM25 over 264 documents |
| Pages in flight | 4 | When page URLs are known up front |
| Default page cap | 20 (max 200) | Per all_pages call |
| Response in memory | โค 64 MiB | Shared across pages; downloads stream regardless of size |
| Text shown to the model | โค 60,000 chars | Then truncated with advice |
| Request timeout | 120 s | None for multipart uploads |
| Token refresh | 60 s before expiry | Or at half-life for short tokens |
-
A newer VSP 360 spec: point
VSP360_SPECat it (JSON loads fastest), or replace the bundled YAML and rebuild. New read-onlyPOSTs go inREAD_ONLY_POSTS, new credential-returning operations inSECRETS, and the counts inspec.test.mjsandserver.test.mjsmove. The 0.1.25 milestone plans this for the Fleet Management API. -
Better search for an area: add example questions to
spec/examples.jsonin the words users use, runnpm run eval, and raise thresholds if scores improve. Synonyms and stopwords live at the top ofsearch.ts. -
More MQL coverage: add resource types or attributes to
spec/analytics-fields.json, marking their source. -
A new per-argument read-only rule: add to
READ_ONLY_PARAMS. -
Another transport:
Serveris transport-agnostic; an HTTP mode would swapStdioServerTransportforStreamableHTTPServerTransportand add TLS, authentication,Originchecks and a credential model. -
Workflow guidance: the plugin's
skills/folder holds SKILL.md files (capacity report, health check) that tell the model which operations to use for a task. More can be added without touching the server.
- One VSP 360 per server process. Several instances mean several server entries, each loading its own tool definitions, and the model can't compare instances in one call. The 0.1.24 milestone addresses this.
- One identity. Everyone using a server entry acts as its service account. VSP 360's own audit trail sees the service account, not the person.
- stdio only. No remote or shared deployment without a third-party gateway.
- Lexical search has a ceiling. It can't infer meaning beyond the synonym table and the example questions; a request phrased in wholly unfamiliar words gets LOW confidence and a suggestion to rephrase. That's the intended behaviour, but it's a limit.
- The MQL reference is partly observed. Host, fabric and VMware types were found on a live system and may be incomplete; the reference says so.
- The learned hint's benefit is modest. It was built to be measured; the A/B comparison, not the design, decides whether it stays on by default.
- Response schemas are depth-limited (6 levels) in describe output, and recursive input schemas are cut with a note.
-
Windows paths are handled through Node's
pathmodule and tested by the package smoke test on Windows machines, but the unit tests run on macOS and Linux. - No streaming of a long response to the client. A response is complete, truncated, or saved to a file; MCP tool results don't stream.
0.1.24: multiple VSP 360 instances. An instances file (VSP360_INSTANCES_FILE) names each instance with its URL, client ID, the environment variable holding its secret, and per-instance TLS and policy (read-only, secret reads, upload folder). The server keeps one Vsp360Client per instance (own token, own agent). vsp360_call_operation and direct tools gain an instance argument, with "all" running a read on every instance in parallel and labelling the results; writes must name one instance. A vsp360_list_instances tool reports names, hosts and policies. Downloads get a folder per instance, and the decision log and startup line record the instance. Single-instance setups keep working unchanged. Roadmap has the full design.
0.1.25: the Fleet Management API. When Hitachi Vantara publishes it, extend the bundled spec, classify the new POSTs and secrets, add example questions, re-run the evaluation and retrain the hint, and document the new areas.
Under consideration, not scheduled:
-
Streamable HTTP transport, for a shared server on a jump host near the storage, reachable from claude.ai and Claude Desktop as a remote connector. It needs authentication (OAuth 2.1 per the MCP authorization spec, or at least bearer tokens), TLS,
Originvalidation, and a decision between one shared service account and per-session VSP 360 credentials. - Code mode: exposing the API as a library the model writes code against in a sandbox, so multi-step, multi-page reports run in one call with intermediate results out of context. VSP 360 reporting questions are a natural fit.
- Client-side deferred tool loading makes a flat tool list usable in clients that support it; the discovery tools remain the portable answer.
| Term | Meaning |
|---|---|
| Operation | One method-and-path pair in the OpenAPI spec, identified by its operationId
|
| Tag / API area | The spec's grouping of operations (Device Registry, Platform Alerts, ...); 35 in the 1.4 spec |
| Meta-tools / discovery tools |
vsp360_list_operations, vsp360_describe_operation, vsp360_call_operation
|
| Direct tool | One MCP tool for one operation, enabled per tag with VSP360_TOOLSETS
|
| Gate | A check in index.ts that refuses a call under the current settings before any request is sent |
| Read-only POST | A POST operation on the allow-list that only queries or validates |
| Secret operation | An operation that returns a live credential; needs VSP360_ALLOW_SECRET_READS
|
| Confidence | HIGH, MEDIUM or LOW, from the top hit's lead over the runner-up and its coverage of the query |
| Decision log | The opt-in local JSONL record of searches, describes and calls |
| Collection mode | A logging mode that forces a labelled search before every call |
| Learned hint | The optional logistic-regression suggestion of what to do after a search |
| MQL | VSP 360 Analytics' query language, used through analyticsRunQuery
|
| Spec cache |
dist/openapi-cache.json, the pre-parsed spec keyed by the YAML's hash |
.mcpb |
The Claude Desktop extension package format |
| Standalone package | An archive with the server and a Node.js runtime, needing nothing installed |