@zenixsolutions/streamlined-mcp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +24 -9
- package/dist/api.js +2 -2
- package/dist/cli.js +42 -0
- package/dist/config.js +41 -9
- package/dist/http.js +351 -0
- package/dist/index.js +70 -13
- package/dist/server.js +13 -7
- package/npm-shrinkwrap.json +2 -2
- package/package.json +3 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — unreleased
|
|
4
|
+
|
|
5
|
+
- **HTTP mode** (`--http [--port N] [--host H]`): MCP Streamable HTTP at `/mcp` on `127.0.0.1:3100`, for hosting behind Nginx (RFC-002). Stateless; serves 2025-era and 2026-07-28 clients.
|
|
6
|
+
- Each request authenticates with the caller's own API token (`Authorization: Bearer`). Any token may connect; it is checked with the app, cached only as a hash for 5 minutes, and used for that request only. No secret is configured on the server: `STREAMLINED_TOKEN` and `STREAMLINED_EXTRA_HEADER_*` are refused in this mode.
|
|
7
|
+
- Limits: 32 requests in flight, 30 checks per minute of tokens the app hasn't accepted in the last 24 hours (accepted tokens are exempt), 128 KB bodies, JSON only. Browser requests (`Origin`) and `subscriptions/listen` are refused.
|
|
8
|
+
- Graceful shutdown: on `TERM`, in-flight requests get up to 25 seconds to finish.
|
|
9
|
+
- `GET /healthz` for monitoring.
|
|
10
|
+
- stdio mode is unchanged and remains the default.
|
|
11
|
+
- `--help` and `--version`.
|
|
12
|
+
- The 401 message no longer names `STREAMLINED_TOKEN`, since the token may come from a header.
|
|
13
|
+
|
|
14
|
+
## 0.1.1 — 2026-10-19
|
|
15
|
+
|
|
16
|
+
- No functional changes.
|
|
17
|
+
- First release published by GitHub Actions through npm trusted publishing (OIDC). No npm token was used.
|
|
18
|
+
|
|
19
|
+
## 0.1.0 — 2026-10-19
|
|
20
|
+
|
|
21
|
+
First release (published by hand).
|
|
22
|
+
|
|
23
|
+
- `streamlined_list_new_orders`: the cursor-paginated queue of New orders with a reference number and no contact, oldest first. Contacts are matched to the order's record type, with a fallback to the provider's details. The 4 latest notes are included as plain text.
|
|
24
|
+
- `streamlined_list_record_types`.
|
|
25
|
+
- `streamlined_add_order_note`: internal notes only, guarded by `reference_num`. Off unless `STREAMLINED_ENABLE_WRITES=true`.
|
package/README.md
CHANGED
|
@@ -2,27 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
An MCP server (stdio) that lets an AI agent work the Streamlined Record Retrieval order queue: list New orders that have a reference number and no contact yet, see each order's provider, matching contacts and latest notes, and add internal notes.
|
|
4
4
|
|
|
5
|
-
It runs
|
|
5
|
+
It runs in one of two modes:
|
|
6
|
+
|
|
7
|
+
- **stdio** (default): a subprocess of a local MCP client. Opens no ports; the token comes from `STREAMLINED_TOKEN`.
|
|
8
|
+
- **HTTP** (`--http`): a Streamable HTTP server for a reverse proxy, at `/mcp` on `127.0.0.1:3100`. It holds no token: every request must send the caller's own API token as `Authorization: Bearer <token>`.
|
|
6
9
|
|
|
7
10
|
## Install / run
|
|
8
11
|
|
|
9
12
|
```sh
|
|
10
|
-
npx -y @zenixsolutions/streamlined-mcp@0.
|
|
13
|
+
npx -y @zenixsolutions/streamlined-mcp@0.2.0 # stdio
|
|
14
|
+
npx -y @zenixsolutions/streamlined-mcp@0.2.0 --http --port 3100 # HTTP, behind Nginx
|
|
11
15
|
```
|
|
12
16
|
|
|
13
|
-
Requires Node.js 20.3 or later. Pin the version
|
|
17
|
+
Requires Node.js 20.3 or later. Pin the version.
|
|
14
18
|
|
|
15
19
|
## Configuration
|
|
16
20
|
|
|
17
|
-
| Variable | Required | Description |
|
|
18
|
-
|
|
19
|
-
| `STREAMLINED_BASE_URL` | yes | Base URL of the app, `https://` only, e.g. `https://admin.example.com` |
|
|
20
|
-
| `STREAMLINED_TOKEN` | yes | API token for the integration's service user. Reading needs no special permission; `notes:write` is needed to add notes |
|
|
21
|
-
| `STREAMLINED_ENABLE_WRITES` | no | `true` to register the note tool. Default: read-only |
|
|
22
|
-
| `STREAMLINED_EXTRA_HEADER_NAME` / `STREAMLINED_EXTRA_HEADER_VALUE` | no | An extra header sent on every request, e.g. a secret that a Cloudflare rule checks |
|
|
21
|
+
| Variable | Mode | Required | Description |
|
|
22
|
+
|---|---|---|---|
|
|
23
|
+
| `STREAMLINED_BASE_URL` | both | yes | Base URL of the app, `https://` only, e.g. `https://admin.example.com` |
|
|
24
|
+
| `STREAMLINED_TOKEN` | stdio | yes | API token for the integration's service user. Reading needs no special permission; `notes:write` is needed to add notes. **Refused in HTTP mode** |
|
|
25
|
+
| `STREAMLINED_ENABLE_WRITES` | both | no | `true` to register the note tool. Default: read-only |
|
|
26
|
+
| `STREAMLINED_EXTRA_HEADER_NAME` / `STREAMLINED_EXTRA_HEADER_VALUE` | stdio | no | An extra header sent to the app on every request, e.g. a secret that a Cloudflare rule checks. **Refused in HTTP mode** |
|
|
27
|
+
| `STREAMLINED_HTTP_PORT` / `STREAMLINED_HTTP_HOST` | HTTP | no | Default `3100` / `127.0.0.1`. `--port` and `--host` take precedence |
|
|
28
|
+
| `STREAMLINED_HTTP_ALLOW_REMOTE_BIND` | HTTP | no | `true` to allow a non-loopback host. Only for a private network: the server speaks plain HTTP |
|
|
23
29
|
|
|
24
30
|
The server exits with a clear message if the configuration is invalid. Secrets are never printed.
|
|
25
31
|
|
|
32
|
+
## HTTP mode
|
|
33
|
+
|
|
34
|
+
- `POST /mcp` (MCP Streamable HTTP, stateless; 2025-era and 2026-07-28 clients) and `GET /healthz`. Everything else returns 404.
|
|
35
|
+
- Every `/mcp` request needs `Authorization: Bearer <API token>`. Any token may connect: it is checked with the app (`GET /api/order_types`), which accepts or rejects it, and the result is cached for 5 minutes, as a hash only. The tools then call the app with that same token, for that request only.
|
|
36
|
+
- Rejected: requests with an `Origin` header (browsers) with 403; missing or malformed tokens with 401, without contacting the app; tokens the app refuses with 401; more than 30 checks a minute of tokens the app hasn't accepted in the last 24 hours with 429 (tokens accepted in that time are exempt; the exemption is in memory, so it resets on restart); more than 32 requests in flight with 503; non-JSON bodies with 415; bodies over 128 KB with 413; `subscriptions/listen` with 400.
|
|
37
|
+
- Logs: one line per request (method, route, status, duration). No headers, bodies, tokens or IP addresses.
|
|
38
|
+
|
|
39
|
+
See `docs/DEPLOYMENT.md` in the repository for the Forge, Nginx and Cloudflare setup.
|
|
40
|
+
|
|
26
41
|
## Tools
|
|
27
42
|
|
|
28
43
|
| Tool | Kind | What it does |
|
package/dist/api.js
CHANGED
|
@@ -16,7 +16,7 @@ export class ApiError extends Error {
|
|
|
16
16
|
function statusMessage(status, detail) {
|
|
17
17
|
switch (status) {
|
|
18
18
|
case 401:
|
|
19
|
-
return "The Streamlined API rejected the token (401).
|
|
19
|
+
return "The Streamlined API rejected the token (401). It may be wrong or revoked.";
|
|
20
20
|
case 403:
|
|
21
21
|
return "The token is not allowed to do this (403). Writing notes needs the notes:write permission on the token.";
|
|
22
22
|
case 404:
|
|
@@ -164,7 +164,7 @@ export class StreamlinedClient {
|
|
|
164
164
|
if (!response.ok) {
|
|
165
165
|
// A Cloudflare challenge page comes back as HTML 403
|
|
166
166
|
if (response.status === 403 && !contentType.includes("application/json")) {
|
|
167
|
-
throw new ApiError(403, "Blocked before reaching the app (Cloudflare challenge). The Cloudflare skip rule or STREAMLINED_EXTRA_HEADER_* settings need attention.");
|
|
167
|
+
throw new ApiError(403, "Blocked before reaching the app (Cloudflare challenge). The Cloudflare skip rule or STREAMLINED_EXTRA_HEADER_* settings need attention.", "blocked");
|
|
168
168
|
}
|
|
169
169
|
const detail = response.status === 422 ? validationDetail(body, (s) => this.redact(s)) : undefined;
|
|
170
170
|
throw new ApiError(response.status, statusMessage(response.status, detail ? oneLine(detail) : undefined));
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ConfigError } from "./config.js";
|
|
2
|
+
import { SERVER_NAME } from "./server.js";
|
|
3
|
+
export const USAGE = `Usage: ${SERVER_NAME} [--http [--port N] [--host H]] [--version] [--help]
|
|
4
|
+
|
|
5
|
+
Without arguments, runs on stdio (set STREAMLINED_BASE_URL and STREAMLINED_TOKEN).
|
|
6
|
+
--http runs a Streamable HTTP server for a reverse proxy (default 127.0.0.1:3100, path /mcp).
|
|
7
|
+
Clients authenticate with 'Authorization: Bearer <Streamlined API token>'.`;
|
|
8
|
+
export function parseArgs(argv) {
|
|
9
|
+
const cli = { mode: "stdio" };
|
|
10
|
+
for (let i = 0; i < argv.length; i++) {
|
|
11
|
+
const arg = argv[i];
|
|
12
|
+
const [flag, inline] = arg.startsWith("--") && arg.includes("=") ? [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)] : [arg, undefined];
|
|
13
|
+
const value = () => {
|
|
14
|
+
const v = inline ?? argv[++i];
|
|
15
|
+
if (v === undefined || v === "")
|
|
16
|
+
throw new ConfigError(`${flag} needs a value.`);
|
|
17
|
+
return v;
|
|
18
|
+
};
|
|
19
|
+
switch (flag) {
|
|
20
|
+
case "--http":
|
|
21
|
+
cli.mode = "http";
|
|
22
|
+
break;
|
|
23
|
+
case "--port":
|
|
24
|
+
cli.port = value();
|
|
25
|
+
break;
|
|
26
|
+
case "--host":
|
|
27
|
+
cli.host = value();
|
|
28
|
+
break;
|
|
29
|
+
case "--help":
|
|
30
|
+
case "-h":
|
|
31
|
+
return { mode: "help" };
|
|
32
|
+
case "--version":
|
|
33
|
+
case "-v":
|
|
34
|
+
return { mode: "version" };
|
|
35
|
+
default:
|
|
36
|
+
throw new ConfigError(`Unknown argument: ${arg.slice(0, 40)}\n\n${USAGE}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (cli.mode === "stdio" && (cli.port !== undefined || cli.host !== undefined))
|
|
40
|
+
throw new ConfigError("--port and --host need --http.");
|
|
41
|
+
return cli;
|
|
42
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Configuration comes only from environment variables
|
|
3
|
-
* contains no hostnames, tokens or other
|
|
2
|
+
* Configuration comes only from environment variables (and, for HTTP mode, the
|
|
3
|
+
* command line). The published package contains no hostnames, tokens or other
|
|
4
|
+
* client-specific values.
|
|
4
5
|
*/
|
|
5
6
|
export class ConfigError extends Error {
|
|
6
7
|
}
|
|
7
8
|
const HEADER_NAME = /^[A-Za-z0-9-]{1,64}$/;
|
|
8
9
|
const RESERVED_HEADERS = new Set(["authorization", "accept", "content-type", "host", "cookie", "user-agent", "content-length"]);
|
|
9
|
-
|
|
10
|
+
const TOKEN_PATTERN = /^[\x21-\x7e]+$/;
|
|
11
|
+
export const DEFAULT_HTTP_PORT = 3100;
|
|
12
|
+
export const DEFAULT_HTTP_HOST = "127.0.0.1";
|
|
13
|
+
const LOOPBACK = new Set(["127.0.0.1", "::1"]);
|
|
14
|
+
function loadCommon(env) {
|
|
10
15
|
const rawBase = env.STREAMLINED_BASE_URL?.trim();
|
|
11
16
|
if (!rawBase)
|
|
12
17
|
throw new ConfigError("STREAMLINED_BASE_URL is required (for example https://admin.example.com).");
|
|
@@ -24,11 +29,6 @@ export function loadConfig(env) {
|
|
|
24
29
|
if (url.search || url.hash)
|
|
25
30
|
throw new ConfigError("STREAMLINED_BASE_URL must not contain a query string or fragment.");
|
|
26
31
|
const baseUrl = url.origin + url.pathname.replace(/\/+$/, "");
|
|
27
|
-
const token = env.STREAMLINED_TOKEN?.trim();
|
|
28
|
-
if (!token)
|
|
29
|
-
throw new ConfigError("STREAMLINED_TOKEN is required.");
|
|
30
|
-
if (!/^[\x21-\x7e]+$/.test(token))
|
|
31
|
-
throw new ConfigError("STREAMLINED_TOKEN must be one line of visible ASCII with no whitespace; check how it was pasted.");
|
|
32
32
|
const headerName = env.STREAMLINED_EXTRA_HEADER_NAME?.trim();
|
|
33
33
|
const headerValue = env.STREAMLINED_EXTRA_HEADER_VALUE?.trim();
|
|
34
34
|
let extraHeader;
|
|
@@ -45,5 +45,37 @@ export function loadConfig(env) {
|
|
|
45
45
|
}
|
|
46
46
|
const writes = (env.STREAMLINED_ENABLE_WRITES ?? "").trim().toLowerCase();
|
|
47
47
|
const enableWrites = writes === "true" || writes === "1" || writes === "yes";
|
|
48
|
-
return { baseUrl,
|
|
48
|
+
return extraHeader ? { baseUrl, extraHeader, enableWrites } : { baseUrl, enableWrites };
|
|
49
|
+
}
|
|
50
|
+
/** stdio mode: one token for the whole process, from STREAMLINED_TOKEN. */
|
|
51
|
+
export function loadConfig(env) {
|
|
52
|
+
const common = loadCommon(env);
|
|
53
|
+
const token = env.STREAMLINED_TOKEN?.trim();
|
|
54
|
+
if (!token)
|
|
55
|
+
throw new ConfigError("STREAMLINED_TOKEN is required.");
|
|
56
|
+
if (!TOKEN_PATTERN.test(token))
|
|
57
|
+
throw new ConfigError("STREAMLINED_TOKEN must be one line of visible ASCII with no whitespace; check how it was pasted.");
|
|
58
|
+
return { ...common, token };
|
|
59
|
+
}
|
|
60
|
+
/** HTTP mode. `options` come from the command line and take precedence over STREAMLINED_HTTP_HOST / STREAMLINED_HTTP_PORT. */
|
|
61
|
+
export function loadHttpConfig(env, options = {}) {
|
|
62
|
+
if (env.STREAMLINED_TOKEN !== undefined) {
|
|
63
|
+
throw new ConfigError("STREAMLINED_TOKEN must not be set in HTTP mode: every client sends its own token in the Authorization header. Remove it from the daemon's environment.");
|
|
64
|
+
}
|
|
65
|
+
if (env.STREAMLINED_EXTRA_HEADER_NAME !== undefined || env.STREAMLINED_EXTRA_HEADER_VALUE !== undefined) {
|
|
66
|
+
throw new ConfigError("STREAMLINED_EXTRA_HEADER_* must not be set in HTTP mode: the server holds no secrets. Allow the server's IP in the Cloudflare /api/* skip rule instead.");
|
|
67
|
+
}
|
|
68
|
+
const base = loadCommon(env);
|
|
69
|
+
const rawPort = (options.port ?? env.STREAMLINED_HTTP_PORT ?? String(DEFAULT_HTTP_PORT)).trim();
|
|
70
|
+
if (!/^\d{1,5}$/.test(rawPort) || Number(rawPort) < 1 || Number(rawPort) > 65535) {
|
|
71
|
+
throw new ConfigError("The HTTP port must be a number from 1 to 65535.");
|
|
72
|
+
}
|
|
73
|
+
const host = (options.host ?? env.STREAMLINED_HTTP_HOST ?? DEFAULT_HTTP_HOST).trim();
|
|
74
|
+
if (!/^[A-Za-z0-9.:-]{1,253}$/.test(host))
|
|
75
|
+
throw new ConfigError("The HTTP host must be an IP address or hostname.");
|
|
76
|
+
const allowRemote = (env.STREAMLINED_HTTP_ALLOW_REMOTE_BIND ?? "").trim().toLowerCase() === "true";
|
|
77
|
+
if (!LOOPBACK.has(host) && !allowRemote) {
|
|
78
|
+
throw new ConfigError(`Refusing to listen on ${host}: HTTP mode speaks plain HTTP and must sit behind a TLS proxy on the same machine. Use 127.0.0.1 or ::1, or set STREAMLINED_HTTP_ALLOW_REMOTE_BIND=true if a private network carries the traffic.`);
|
|
79
|
+
}
|
|
80
|
+
return { base, host, port: Number(rawPort) };
|
|
49
81
|
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP mode: MCP Streamable HTTP on loopback, behind a TLS-terminating proxy (Nginx on Forge).
|
|
3
|
+
*
|
|
4
|
+
* Every request must carry the caller's own Streamlined API token as `Authorization: Bearer`.
|
|
5
|
+
* The process holds no credential of its own: the token is checked against the API, cached only
|
|
6
|
+
* as a SHA-256 hash, and used for that one request. Nothing about a request is logged except the
|
|
7
|
+
* method, the route, the status and the duration.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { once } from "node:events";
|
|
11
|
+
import { createServer as createNodeServer } from "node:http";
|
|
12
|
+
import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
13
|
+
import { ApiError, StreamlinedClient } from "./api.js";
|
|
14
|
+
import { createServer, RecordTypeCache } from "./server.js";
|
|
15
|
+
export const MCP_PATH = "/mcp";
|
|
16
|
+
export const HEALTH_PATH = "/healthz";
|
|
17
|
+
export const MAX_REQUEST_BYTES = 128 * 1024;
|
|
18
|
+
export const MAX_IN_FLIGHT = 32;
|
|
19
|
+
export const TOKEN_TTL_MS = 5 * 60 * 1000;
|
|
20
|
+
export const UNKNOWN_TOKEN_CHECKS_PER_MINUTE = 30;
|
|
21
|
+
/** A token the API accepted within this window is re-checked outside the per-minute budget, so junk tokens cannot lock it out. */
|
|
22
|
+
export const KNOWN_TOKEN_MS = 24 * 60 * 60 * 1000;
|
|
23
|
+
export const MAX_CACHED_TOKENS = 1000;
|
|
24
|
+
const TOKEN_PATTERN = /^[\x21-\x7e]{1,512}$/;
|
|
25
|
+
const BEARER = /^Bearer +(\S+) *$/i;
|
|
26
|
+
/** Request headers passed through to the MCP handler (plus any `mcp-param-*`). Authorization is deliberately not one of them. */
|
|
27
|
+
const FORWARDED_HEADERS = new Set(["content-type", "accept", "mcp-protocol-version", "mcp-session-id", "last-event-id", "mcp-method", "mcp-name"]);
|
|
28
|
+
/** Long-lived streams: the tools never change, so there is nothing to subscribe to, and open streams would hold capacity. */
|
|
29
|
+
const REFUSED_METHODS = new Set(["subscriptions/listen"]);
|
|
30
|
+
export const SHUTDOWN_GRACE_MS = 25_000;
|
|
31
|
+
export function hashToken(token) {
|
|
32
|
+
return createHash("sha256").update(token).digest("hex");
|
|
33
|
+
}
|
|
34
|
+
function remember(map, key, value) {
|
|
35
|
+
map.delete(key);
|
|
36
|
+
map.set(key, value);
|
|
37
|
+
while (map.size > MAX_CACHED_TOKENS)
|
|
38
|
+
map.delete(map.keys().next().value);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Any token may connect; the API decides. This class only avoids re-asking the API on every
|
|
42
|
+
* request (accepted tokens are trusted for TOKEN_TTL_MS) and stops junk tokens from flooding
|
|
43
|
+
* the API through this server: at most UNKNOWN_TOKEN_CHECKS_PER_MINUTE checks of tokens the
|
|
44
|
+
* API has not accepted in the last KNOWN_TOKEN_MS. Tokens are held only as SHA-256 hashes.
|
|
45
|
+
*/
|
|
46
|
+
export class TokenVerifier {
|
|
47
|
+
check;
|
|
48
|
+
now;
|
|
49
|
+
verified = new Map();
|
|
50
|
+
known = new Map();
|
|
51
|
+
pending = new Map();
|
|
52
|
+
windowStart = 0;
|
|
53
|
+
windowCount = 0;
|
|
54
|
+
constructor(check, now = Date.now) {
|
|
55
|
+
this.check = check;
|
|
56
|
+
this.now = now;
|
|
57
|
+
}
|
|
58
|
+
async verify(token) {
|
|
59
|
+
const key = hashToken(token);
|
|
60
|
+
const now = this.now();
|
|
61
|
+
const until = this.verified.get(key);
|
|
62
|
+
if (until !== undefined) {
|
|
63
|
+
if (until > now)
|
|
64
|
+
return "ok";
|
|
65
|
+
this.verified.delete(key);
|
|
66
|
+
}
|
|
67
|
+
const inFlight = this.pending.get(key);
|
|
68
|
+
if (inFlight)
|
|
69
|
+
return inFlight;
|
|
70
|
+
const acceptedAt = this.known.get(key);
|
|
71
|
+
const exempt = acceptedAt !== undefined && now - acceptedAt < KNOWN_TOKEN_MS;
|
|
72
|
+
if (!exempt) {
|
|
73
|
+
if (now - this.windowStart >= 60_000) {
|
|
74
|
+
this.windowStart = now;
|
|
75
|
+
this.windowCount = 0;
|
|
76
|
+
}
|
|
77
|
+
if (this.windowCount >= UNKNOWN_TOKEN_CHECKS_PER_MINUTE)
|
|
78
|
+
return "limited";
|
|
79
|
+
this.windowCount += 1;
|
|
80
|
+
}
|
|
81
|
+
const run = this.run(token, key).finally(() => this.pending.delete(key));
|
|
82
|
+
this.pending.set(key, run);
|
|
83
|
+
return run;
|
|
84
|
+
}
|
|
85
|
+
async run(token, key) {
|
|
86
|
+
try {
|
|
87
|
+
await this.check(token);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error instanceof ApiError) {
|
|
91
|
+
if (error.kind === "http" && (error.status === 401 || error.status === 403)) {
|
|
92
|
+
this.known.delete(key);
|
|
93
|
+
return "invalid";
|
|
94
|
+
}
|
|
95
|
+
if (error.kind === "timeout")
|
|
96
|
+
return "timeout";
|
|
97
|
+
}
|
|
98
|
+
return "unavailable";
|
|
99
|
+
}
|
|
100
|
+
const now = this.now();
|
|
101
|
+
remember(this.verified, key, now + TOKEN_TTL_MS);
|
|
102
|
+
remember(this.known, key, now);
|
|
103
|
+
return "ok";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function bearerToken(header) {
|
|
107
|
+
const match = header ? BEARER.exec(header) : null;
|
|
108
|
+
const token = match?.[1];
|
|
109
|
+
return token && TOKEN_PATTERN.test(token) ? token : undefined;
|
|
110
|
+
}
|
|
111
|
+
class RequestTooLarge extends Error {
|
|
112
|
+
}
|
|
113
|
+
async function readBody(req, limit) {
|
|
114
|
+
const chunks = [];
|
|
115
|
+
let size = 0;
|
|
116
|
+
for await (const chunk of req) {
|
|
117
|
+
const buf = chunk;
|
|
118
|
+
size += buf.byteLength;
|
|
119
|
+
if (size > limit)
|
|
120
|
+
throw new RequestTooLarge();
|
|
121
|
+
chunks.push(buf);
|
|
122
|
+
}
|
|
123
|
+
return Buffer.concat(chunks, size);
|
|
124
|
+
}
|
|
125
|
+
function sendError(res, status, message, headers = {}) {
|
|
126
|
+
const body = JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message }, id: null });
|
|
127
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", ...headers });
|
|
128
|
+
res.end(body);
|
|
129
|
+
return status;
|
|
130
|
+
}
|
|
131
|
+
async function writeResponse(res, response) {
|
|
132
|
+
const headers = { "cache-control": "no-store" };
|
|
133
|
+
response.headers.forEach((value, key) => {
|
|
134
|
+
if (key !== "content-length" && key !== "transfer-encoding" && key !== "connection")
|
|
135
|
+
headers[key] = value;
|
|
136
|
+
});
|
|
137
|
+
res.writeHead(response.status, headers);
|
|
138
|
+
if (response.body) {
|
|
139
|
+
const reader = response.body.getReader();
|
|
140
|
+
try {
|
|
141
|
+
for (;;) {
|
|
142
|
+
const { done, value } = await reader.read();
|
|
143
|
+
if (done || res.destroyed)
|
|
144
|
+
break;
|
|
145
|
+
// Wait for the socket to drain, or stop if the client went away meanwhile
|
|
146
|
+
if (!res.write(value)) {
|
|
147
|
+
const done = new AbortController();
|
|
148
|
+
await Promise.race([once(res, "drain", { signal: done.signal }), once(res, "close", { signal: done.signal })]).catch(() => undefined);
|
|
149
|
+
done.abort();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
await reader.cancel().catch(() => undefined);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!res.destroyed)
|
|
158
|
+
res.end();
|
|
159
|
+
return response.status;
|
|
160
|
+
}
|
|
161
|
+
/** The request path without its query. Plain string handling: request targets are attacker-controlled, so no URL parsing. */
|
|
162
|
+
function pathOf(target) {
|
|
163
|
+
const raw = target ?? "/";
|
|
164
|
+
const q = raw.indexOf("?");
|
|
165
|
+
return q === -1 ? raw : raw.slice(0, q);
|
|
166
|
+
}
|
|
167
|
+
function route(pathname) {
|
|
168
|
+
return pathname === MCP_PATH || pathname === HEALTH_PATH ? pathname : "(other)";
|
|
169
|
+
}
|
|
170
|
+
/** JSON-RPC methods named in a request body (single message or batch); empty if the body is not JSON. */
|
|
171
|
+
function methodsIn(body) {
|
|
172
|
+
try {
|
|
173
|
+
// Decode exactly as the SDK does (TextDecoder strips a UTF-8 BOM), so both see the same messages
|
|
174
|
+
const parsed = JSON.parse(new TextDecoder().decode(body));
|
|
175
|
+
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
|
176
|
+
return messages.flatMap((m) => (m && typeof m === "object" && typeof m.method === "string" ? [m.method] : []));
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Builds the request listener; exported separately so tests can drive it without a port. */
|
|
183
|
+
export function createHttpApp(config, deps = {}) {
|
|
184
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
185
|
+
const log = deps.log ?? ((line) => console.error(line));
|
|
186
|
+
const now = deps.now ?? Date.now;
|
|
187
|
+
const types = new RecordTypeCache(undefined, now);
|
|
188
|
+
const clientFor = (token) => new StreamlinedClient({ ...config.base, token }, fetchImpl);
|
|
189
|
+
const verifier = new TokenVerifier(async (token) => {
|
|
190
|
+
await clientFor(token).request("GET", "order_types");
|
|
191
|
+
}, now);
|
|
192
|
+
const handler = createMcpHandler((ctx) => {
|
|
193
|
+
const token = ctx.authInfo?.token;
|
|
194
|
+
// Never reached without a verified token: the listener below supplies authInfo only after verification.
|
|
195
|
+
if (!token)
|
|
196
|
+
throw new Error("unauthenticated request reached the MCP handler");
|
|
197
|
+
return createServer({ ...config.base, token }, clientFor(token), types);
|
|
198
|
+
}, {
|
|
199
|
+
// The tools emit no progress notifications, so responses are plain JSON in practice
|
|
200
|
+
maxRequestBodySize: MAX_REQUEST_BYTES,
|
|
201
|
+
// Belt and braces: subscriptions are refused before reaching the handler
|
|
202
|
+
maxSubscriptions: 1,
|
|
203
|
+
// Error messages can quote request content, so only the error's name is logged.
|
|
204
|
+
onerror: (error) => log(`mcp: ${error.name}`)
|
|
205
|
+
});
|
|
206
|
+
let inFlight = 0;
|
|
207
|
+
async function serve(req, res) {
|
|
208
|
+
const pathname = pathOf(req.url);
|
|
209
|
+
const method = req.method ?? "GET";
|
|
210
|
+
if (pathname === HEALTH_PATH && (method === "GET" || method === "HEAD")) {
|
|
211
|
+
res.writeHead(200, { "content-type": "text/plain", "cache-control": "no-store" });
|
|
212
|
+
res.end(method === "HEAD" ? undefined : "ok");
|
|
213
|
+
return 200;
|
|
214
|
+
}
|
|
215
|
+
if (pathname !== MCP_PATH)
|
|
216
|
+
return sendError(res, 404, "Not found.");
|
|
217
|
+
// MCP clients do not send Origin; browsers do. Refusing them stops cross-site use of a token.
|
|
218
|
+
if (req.headers.origin !== undefined)
|
|
219
|
+
return sendError(res, 403, "Browser requests are not accepted.");
|
|
220
|
+
if (method !== "POST" && method !== "GET" && method !== "DELETE")
|
|
221
|
+
return sendError(res, 405, "Method not allowed.", { allow: "POST, GET, DELETE" });
|
|
222
|
+
const token = bearerToken(req.headers.authorization);
|
|
223
|
+
if (!token) {
|
|
224
|
+
return sendError(res, 401, "Send your Streamlined API token as 'Authorization: Bearer <token>'.", {
|
|
225
|
+
"www-authenticate": 'Bearer realm="streamlined-mcp"'
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
let body;
|
|
229
|
+
if (method === "POST") {
|
|
230
|
+
const type = (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
|
|
231
|
+
if (type !== "application/json")
|
|
232
|
+
return sendError(res, 415, "Content-Type must be application/json.");
|
|
233
|
+
const declared = Number(req.headers["content-length"] ?? NaN);
|
|
234
|
+
if (Number.isFinite(declared) && declared > MAX_REQUEST_BYTES)
|
|
235
|
+
return sendError(res, 413, "Request body too large.");
|
|
236
|
+
}
|
|
237
|
+
switch (await verifier.verify(token)) {
|
|
238
|
+
case "ok":
|
|
239
|
+
break;
|
|
240
|
+
case "invalid":
|
|
241
|
+
return sendError(res, 401, "The Streamlined API rejected this token. It may be wrong or revoked.", {
|
|
242
|
+
"www-authenticate": 'Bearer realm="streamlined-mcp", error="invalid_token"'
|
|
243
|
+
});
|
|
244
|
+
case "limited":
|
|
245
|
+
return sendError(res, 429, "Too many unrecognised tokens. Try again in a minute.", { "retry-after": "60" });
|
|
246
|
+
case "timeout":
|
|
247
|
+
return sendError(res, 504, "The Streamlined API did not respond while checking the token. Try again.");
|
|
248
|
+
case "unavailable":
|
|
249
|
+
return sendError(res, 502, "Could not check the token with the Streamlined API. Try again later.");
|
|
250
|
+
}
|
|
251
|
+
if (method === "POST") {
|
|
252
|
+
try {
|
|
253
|
+
body = await readBody(req, MAX_REQUEST_BYTES);
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (error instanceof RequestTooLarge)
|
|
257
|
+
return sendError(res, 413, "Request body too large.", { connection: "close" });
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const header = req.headers["mcp-method"];
|
|
262
|
+
if ((typeof header === "string" && REFUSED_METHODS.has(header)) || (body && methodsIn(body).some((m) => REFUSED_METHODS.has(m)))) {
|
|
263
|
+
return sendError(res, 400, "Subscriptions are not supported by this server.");
|
|
264
|
+
}
|
|
265
|
+
const abort = new AbortController();
|
|
266
|
+
res.once("close", () => {
|
|
267
|
+
if (!res.writableFinished)
|
|
268
|
+
abort.abort();
|
|
269
|
+
});
|
|
270
|
+
const headers = new Headers();
|
|
271
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
272
|
+
if (typeof value === "string" && (FORWARDED_HEADERS.has(name) || name.startsWith("mcp-param-")))
|
|
273
|
+
headers.set(name, value);
|
|
274
|
+
}
|
|
275
|
+
const request = new Request(`http://localhost${MCP_PATH}`, {
|
|
276
|
+
method,
|
|
277
|
+
headers,
|
|
278
|
+
body: body ? new Uint8Array(body) : undefined,
|
|
279
|
+
signal: abort.signal
|
|
280
|
+
});
|
|
281
|
+
const response = await handler.fetch(request, { authInfo: { token, clientId: "streamlined-api-token", scopes: [] } });
|
|
282
|
+
return writeResponse(res, response);
|
|
283
|
+
}
|
|
284
|
+
/** Never rejects: every failure becomes a 500 or a dropped connection, never an unhandled rejection. */
|
|
285
|
+
async function listener(req, res) {
|
|
286
|
+
const started = now();
|
|
287
|
+
let status = 500;
|
|
288
|
+
let label = "(other)";
|
|
289
|
+
try {
|
|
290
|
+
label = route(pathOf(req.url));
|
|
291
|
+
if (label === MCP_PATH && inFlight >= MAX_IN_FLIGHT) {
|
|
292
|
+
status = sendError(res, 503, "Server busy. Try again shortly.", { "retry-after": "5" });
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
inFlight += 1;
|
|
296
|
+
try {
|
|
297
|
+
status = await serve(req, res);
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
inFlight -= 1;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
log(`http: ${error instanceof Error ? error.name : "error"}`);
|
|
306
|
+
try {
|
|
307
|
+
if (!res.headersSent)
|
|
308
|
+
status = sendError(res, 500, "Internal error.");
|
|
309
|
+
else
|
|
310
|
+
res.destroy();
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
res.destroy();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
log(`${req.method ?? "GET"} ${label} ${status} ${now() - started}ms`);
|
|
317
|
+
}
|
|
318
|
+
return { listener, handler, verifier, types, inFlight: () => inFlight };
|
|
319
|
+
}
|
|
320
|
+
export async function startHttpServer(config, deps = {}) {
|
|
321
|
+
const app = createHttpApp(config, deps);
|
|
322
|
+
const server = createNodeServer({ maxHeaderSize: 16 * 1024 }, (req, res) => void app.listener(req, res));
|
|
323
|
+
server.headersTimeout = 15_000;
|
|
324
|
+
server.requestTimeout = 30_000;
|
|
325
|
+
server.keepAliveTimeout = 5_000;
|
|
326
|
+
await new Promise((resolve, reject) => {
|
|
327
|
+
server.once("error", reject);
|
|
328
|
+
server.listen(config.port, config.host, () => {
|
|
329
|
+
server.off("error", reject);
|
|
330
|
+
resolve();
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
const address = server.address();
|
|
334
|
+
const host = address.family === "IPv6" ? `[${address.address}]` : address.address;
|
|
335
|
+
return {
|
|
336
|
+
server,
|
|
337
|
+
handler: app.handler,
|
|
338
|
+
url: `http://${host}:${address.port}${MCP_PATH}`,
|
|
339
|
+
/** Stops accepting connections, lets in-flight requests finish (up to graceMs), then closes everything. */
|
|
340
|
+
close: async (graceMs = SHUTDOWN_GRACE_MS) => {
|
|
341
|
+
const closed = new Promise((resolve) => server.close(() => resolve()));
|
|
342
|
+
server.closeIdleConnections();
|
|
343
|
+
const deadline = Date.now() + graceMs;
|
|
344
|
+
while (app.inFlight() > 0 && Date.now() < deadline)
|
|
345
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
346
|
+
await app.handler.close();
|
|
347
|
+
server.closeAllConnections();
|
|
348
|
+
await closed;
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,22 +1,79 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Streamlined MCP server
|
|
4
|
-
*
|
|
3
|
+
* Streamlined MCP server.
|
|
4
|
+
*
|
|
5
|
+
* streamlined-mcp stdio (default); token from STREAMLINED_TOKEN
|
|
6
|
+
* streamlined-mcp --http [--port N] [--host H]
|
|
7
|
+
* Streamable HTTP on 127.0.0.1:3100; each client sends its own token
|
|
8
|
+
*
|
|
9
|
+
* Configure with environment variables; see README. In stdio mode stdout carries the MCP
|
|
10
|
+
* protocol, so all diagnostics go to stderr.
|
|
5
11
|
*/
|
|
6
12
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
7
13
|
import { StreamlinedClient } from "./api.js";
|
|
8
|
-
import {
|
|
14
|
+
import { parseArgs, USAGE } from "./cli.js";
|
|
15
|
+
import { ConfigError, loadConfig, loadHttpConfig } from "./config.js";
|
|
16
|
+
import { startHttpServer } from "./http.js";
|
|
9
17
|
import { createServer, RecordTypeCache, SERVER_NAME, SERVER_VERSION } from "./server.js";
|
|
10
|
-
|
|
11
|
-
try {
|
|
12
|
-
config = loadConfig(process.env);
|
|
13
|
-
}
|
|
14
|
-
catch (error) {
|
|
18
|
+
function fail(error) {
|
|
15
19
|
console.error(`${SERVER_NAME}: ${error instanceof ConfigError ? error.message : "invalid configuration"}`);
|
|
16
20
|
process.exit(1);
|
|
17
21
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
// Last line of defence: never let an unexpected error print request data. Log only the error's name.
|
|
23
|
+
process.on("unhandledRejection", (reason) => {
|
|
24
|
+
console.error(`${SERVER_NAME}: unhandled rejection (${reason instanceof Error ? reason.name : typeof reason})`);
|
|
25
|
+
});
|
|
26
|
+
process.on("uncaughtException", (error) => {
|
|
27
|
+
console.error(`${SERVER_NAME}: uncaught exception (${error.name}); exiting`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
});
|
|
30
|
+
async function main() {
|
|
31
|
+
let cli;
|
|
32
|
+
try {
|
|
33
|
+
cli = parseArgs(process.argv.slice(2));
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
fail(error);
|
|
37
|
+
}
|
|
38
|
+
if (cli.mode === "help") {
|
|
39
|
+
console.log(USAGE);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (cli.mode === "version") {
|
|
43
|
+
console.log(SERVER_VERSION);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (cli.mode === "http") {
|
|
47
|
+
let config;
|
|
48
|
+
try {
|
|
49
|
+
config = loadHttpConfig(process.env, { port: cli.port, host: cli.host });
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
fail(error);
|
|
53
|
+
}
|
|
54
|
+
const running = await startHttpServer(config).catch((error) => {
|
|
55
|
+
console.error(`${SERVER_NAME}: could not listen on ${config.host}:${config.port} (${error.code ?? error.name})`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
});
|
|
58
|
+
console.error(`${SERVER_NAME} ${SERVER_VERSION} listening on ${running.url} (writes ${config.base.enableWrites ? "enabled" : "disabled"})`);
|
|
59
|
+
const stop = () => {
|
|
60
|
+
void running.close().finally(() => process.exit(0));
|
|
61
|
+
};
|
|
62
|
+
process.once("SIGTERM", stop);
|
|
63
|
+
process.once("SIGINT", stop);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
let config;
|
|
67
|
+
try {
|
|
68
|
+
config = loadConfig(process.env);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
fail(error);
|
|
72
|
+
}
|
|
73
|
+
const client = new StreamlinedClient(config);
|
|
74
|
+
const types = new RecordTypeCache(client);
|
|
75
|
+
const cfg = config;
|
|
76
|
+
serveStdio(() => createServer(cfg, client, types));
|
|
77
|
+
console.error(`${SERVER_NAME} ${SERVER_VERSION} running on stdio (writes ${cfg.enableWrites ? "enabled" : "disabled"})`);
|
|
78
|
+
}
|
|
79
|
+
void main();
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import * as z from "zod/v4";
|
|
|
3
3
|
import { ApiError } from "./api.js";
|
|
4
4
|
import { clean, CURSOR_PATTERN, OrderPageSchema, OrderTypesSchema, sanitizeNoteInput, shapeOrder } from "./shape.js";
|
|
5
5
|
export const SERVER_NAME = "streamlined-mcp";
|
|
6
|
-
export const SERVER_VERSION = "0.
|
|
6
|
+
export const SERVER_VERSION = "0.2.0";
|
|
7
7
|
export const CHARACTER_LIMIT = 30_000;
|
|
8
8
|
export const MAX_PAGE = 25;
|
|
9
9
|
const TYPE_CACHE_MS = 24 * 60 * 60 * 1000;
|
|
@@ -22,15 +22,21 @@ export class RecordTypeCache {
|
|
|
22
22
|
names = new Map();
|
|
23
23
|
loadedAt = 0;
|
|
24
24
|
failedAt = -Infinity;
|
|
25
|
+
/**
|
|
26
|
+
* `client` is the default caller. In HTTP mode the cache is shared and each call passes the
|
|
27
|
+
* caller's own client, so the lookup always runs with a token that is allowed to make it.
|
|
28
|
+
*/
|
|
25
29
|
constructor(client, now = Date.now) {
|
|
26
30
|
this.client = client;
|
|
27
31
|
this.now = now;
|
|
28
32
|
}
|
|
29
|
-
async get(signal) {
|
|
33
|
+
async get(signal, caller = this.client) {
|
|
30
34
|
if (this.loadedAt && this.now() - this.loadedAt < TYPE_CACHE_MS)
|
|
31
35
|
return this.names;
|
|
36
|
+
if (!caller)
|
|
37
|
+
throw new ApiError(0, "No API client is available to load record types.", "format");
|
|
32
38
|
try {
|
|
33
|
-
const raw = await
|
|
39
|
+
const raw = await caller.request("GET", "order_types", { signal });
|
|
34
40
|
const parsed = OrderTypesSchema.safeParse(raw);
|
|
35
41
|
if (!parsed.success)
|
|
36
42
|
invalid("record type");
|
|
@@ -44,11 +50,11 @@ export class RecordTypeCache {
|
|
|
44
50
|
}
|
|
45
51
|
}
|
|
46
52
|
/** Never let a record-type lookup failure block the order list; after a failure, wait a minute before retrying. */
|
|
47
|
-
async getOrEmpty(signal) {
|
|
53
|
+
async getOrEmpty(signal, caller = this.client) {
|
|
48
54
|
if (this.now() - this.failedAt < TYPE_RETRY_MS)
|
|
49
55
|
return this.names;
|
|
50
56
|
try {
|
|
51
|
-
return await this.get(signal);
|
|
57
|
+
return await this.get(signal, caller);
|
|
52
58
|
}
|
|
53
59
|
catch {
|
|
54
60
|
return this.names;
|
|
@@ -62,7 +68,7 @@ export function createServer(config, client, types) {
|
|
|
62
68
|
description: "List the record types an order can be for (Medical, Billing, Radiology Images, etc.) with their IDs.",
|
|
63
69
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
64
70
|
}, async (ctx) => {
|
|
65
|
-
const names = await types.get(ctx.mcpReq.signal);
|
|
71
|
+
const names = await types.get(ctx.mcpReq.signal, client);
|
|
66
72
|
return result({ record_types: [...names].map(([id, name]) => ({ id, name })) });
|
|
67
73
|
});
|
|
68
74
|
server.registerTool("streamlined_list_new_orders", {
|
|
@@ -84,7 +90,7 @@ ${UNTRUSTED}`,
|
|
|
84
90
|
}),
|
|
85
91
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
86
92
|
}, async ({ limit, cursor }, ctx) => {
|
|
87
|
-
const names = await types.getOrEmpty(ctx.mcpReq.signal);
|
|
93
|
+
const names = await types.getOrEmpty(ctx.mcpReq.signal, client);
|
|
88
94
|
// Stay under CHARACTER_LIMIT by asking the API for fewer orders, so next_cursor never skips any
|
|
89
95
|
let pageSize = limit;
|
|
90
96
|
for (;;) {
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenixsolutions/streamlined-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@zenixsolutions/streamlined-mcp",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.2.0",
|
|
10
10
|
"license": "UNLICENSED",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@modelcontextprotocol/server": "2.3.1",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenixsolutions/streamlined-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server (stdio) for the Streamlined Record Retrieval order queue: list new orders, record types, and add order notes.",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP server (stdio or Streamable HTTP) for the Streamlined Record Retrieval order queue: list new orders, record types, and add order notes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"streamlined-mcp": "dist/index.js"
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"dist",
|
|
11
11
|
"!dist/**/*.map",
|
|
12
12
|
"README.md",
|
|
13
|
+
"CHANGELOG.md",
|
|
13
14
|
"npm-shrinkwrap.json"
|
|
14
15
|
],
|
|
15
16
|
"scripts": {
|