@pharmasync/mcp 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +69 -2
- package/dist/api.d.ts +48 -0
- package/dist/api.js +85 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +138 -0
- package/dist/query.d.ts +71 -0
- package/dist/query.js +105 -0
- package/dist/schemas.d.ts +407 -0
- package/dist/schemas.js +145 -0
- package/dist/version.d.ts +9 -0
- package/dist/version.js +13 -0
- package/package.json +68 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AyeOnGo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,70 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @pharmasync/mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server that makes the **Pharmasync**
|
|
4
|
+
UK medicine-supply data legible to AI agents.
|
|
5
|
+
|
|
6
|
+
It is a **read-only client of the public [`pharmasync.xyz/api/v1`](https://pharmasync.xyz/api/v1)
|
|
7
|
+
artefacts** — a static, open-licensed CDN feed. The server holds no API keys,
|
|
8
|
+
opens no database, and stands up no infrastructure: every answer is computed
|
|
9
|
+
from data any agent could fetch itself, over a single fixed origin. It exposes
|
|
10
|
+
nothing that is not already public; it exists to turn an open contract into
|
|
11
|
+
tools an agent can actually call.
|
|
12
|
+
|
|
13
|
+
## Install & run
|
|
14
|
+
|
|
15
|
+
Requires **Node.js ≥ 20**. No account, no key, no configuration.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx @pharmasync/mcp
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The server speaks MCP over **stdio**, so it is launched by an MCP client rather
|
|
22
|
+
than run by hand.
|
|
23
|
+
|
|
24
|
+
### Wire it into an MCP client
|
|
25
|
+
|
|
26
|
+
Add it to your client's server config. For a Claude Desktop–style
|
|
27
|
+
`mcpServers` block:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"mcpServers": {
|
|
32
|
+
"pharmasync": {
|
|
33
|
+
"command": "npx",
|
|
34
|
+
"args": ["-y", "@pharmasync/mcp"]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Tools
|
|
41
|
+
|
|
42
|
+
All five tools are **read-only** and answer from the public artefacts. Every
|
|
43
|
+
severity tier is a citation of a public government act, reproducible without
|
|
44
|
+
trusting Pharmasync.
|
|
45
|
+
|
|
46
|
+
| Tool | What it returns |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `lookup_product` | A UK medicine by exact dm+d code or a name substring: the product, its current severity tier (with the citing act), and its supply events (MSN / SSP / price concession), newest first. |
|
|
49
|
+
| `current_shortages` | Medicines with a severity tier in force — **WATCH** (a Medicine Supply Notification), **CONCERN** (a restricted-export designation), or **CRITICAL** (a Serious Shortage Protocol) — ranked highest first, each with its citation. Optionally filtered to one tier. |
|
|
50
|
+
| `changes_since` | Detected supply changes from the delta feed. Pass the `cursor` you last stored to get only newer changes; the response carries the next `latest_cursor` to store. |
|
|
51
|
+
| `concessions_for` | The price-concession events for a medicine (by code or name substring), newest first — a financial-exposure and market-stress signal. |
|
|
52
|
+
| `workforce_stats` | The Workforce Panel ledger — the second supply chain: medicines and the people who dispense them. Vacancy rate by role/region, GPhC registrant counts, pharmacy network change by region, and shortage designation status, newest period first. Optionally filtered by `metric`, `role` and/or `region`. Not a jobs board — observed aggregate data only. |
|
|
53
|
+
|
|
54
|
+
## Configuration
|
|
55
|
+
|
|
56
|
+
| Env var | Default | Purpose |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `PHARMASYNC_API` | `https://pharmasync.xyz/api/v1` | The artefact origin the server reads. Every request is pinned to this one host; tool arguments only filter fetched data and never reach a URL. |
|
|
59
|
+
|
|
60
|
+
## Data & provenance
|
|
61
|
+
|
|
62
|
+
The underlying artefacts are published under an open licence and are fully
|
|
63
|
+
disclosed at [`pharmasync.xyz/api/v1`](https://pharmasync.xyz/api/v1)
|
|
64
|
+
(schemas, methodology, coverage, and freshness/status). Data is fetched live at
|
|
65
|
+
tool-call time, so answers are as current as the feed — nothing is bundled or
|
|
66
|
+
cached stale.
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
[MIT](./LICENSE) © AyeOnGo
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A read-only client of the public Pharmasync API (`/api/v1`), for the MCP server (S8).
|
|
3
|
+
*
|
|
4
|
+
* The whole security posture of this package lives here. Every fetch goes to a **single,
|
|
5
|
+
* fixed origin** (`PHARMASYNC_API`, defaulting to the production CDN). Tool arguments filter
|
|
6
|
+
* data that has already been fetched — they never contribute to a URL, a host, or a path.
|
|
7
|
+
* So there is no SSRF surface: an argument cannot redirect a request anywhere, because
|
|
8
|
+
* arguments do not reach `fetch` at all.
|
|
9
|
+
*
|
|
10
|
+
* The data it serves is the same boundary-clean, public logistics data the CDN publishes —
|
|
11
|
+
* no keys, no store, no credentials, nothing a `/api/v1` reader could not fetch itself. The
|
|
12
|
+
* MCP server is a convenience layer over the open contract, not a privileged path to it.
|
|
13
|
+
*/
|
|
14
|
+
import type { z } from 'zod';
|
|
15
|
+
import { ARTEFACT_SCHEMAS, type ArtefactPath, type ChangeRecord, type ChangesIndex, type DmdProduct, type SupplyEvent, type Tier, type WorkforceStat } from './schemas.js';
|
|
16
|
+
/** The one origin every request goes to. A URL like `https://pharmasync.xyz/api/v1`. */
|
|
17
|
+
export declare const API_ORIGIN: string;
|
|
18
|
+
/**
|
|
19
|
+
* Fetch a fixed artefact path relative to the API base, validate it, or throw.
|
|
20
|
+
*
|
|
21
|
+
* `relPath` is a key of `ARTEFACT_SCHEMAS` — a hard-coded literal, never derived from a tool
|
|
22
|
+
* argument. A failed fetch throws rather than returning stale or empty data, and so does a body
|
|
23
|
+
* that does not match the published schema: a tool that cannot read the contract says so, it does
|
|
24
|
+
* not hand an agent a shape it was never promised.
|
|
25
|
+
*/
|
|
26
|
+
export declare const getArtefact: <P extends ArtefactPath>(relPath: P) => Promise<z.infer<(typeof ARTEFACT_SCHEMAS)[P]>>;
|
|
27
|
+
/**
|
|
28
|
+
* Fetch a segment listed in the change feed's own index (`segments[].path`), guarded.
|
|
29
|
+
*
|
|
30
|
+
* The path comes from *our own* published index, not from a tool argument — but it is still
|
|
31
|
+
* validated: it must be an absolute path under `/api/v1/changes/`, and the resolved URL must
|
|
32
|
+
* land on the pinned host. That rejects a protocol-relative `//evil.com/…` from ever being
|
|
33
|
+
* followed, even if a corrupted index somehow carried one. Defense in depth, per §2.3.
|
|
34
|
+
*/
|
|
35
|
+
export declare const getChangeSegment: (pathFromRoot: string) => Promise<ChangeRecord[]>;
|
|
36
|
+
/**
|
|
37
|
+
* Validate a fetched body against its schema, or throw a message a tool can return verbatim.
|
|
38
|
+
*
|
|
39
|
+
* At most three issues are named, each with its JSON path, so an agent (or a human reading the
|
|
40
|
+
* tool error) can see *what* drifted without the message growing with the size of the corpus.
|
|
41
|
+
*/
|
|
42
|
+
export declare const parseArtefact: <S extends z.ZodType>(schema: S, body: unknown, url: string) => z.infer<S>;
|
|
43
|
+
export type { ChangeRecord, ChangeSegmentRef, ChangesIndex, DmdProduct, SupplyEvent, Tier, TierLevel, WorkforceMetric, WorkforceStat, } from './schemas.js';
|
|
44
|
+
export declare const getProducts: () => Promise<DmdProduct[]>;
|
|
45
|
+
export declare const getTiers: () => Promise<Tier[]>;
|
|
46
|
+
export declare const getEvents: () => Promise<SupplyEvent[]>;
|
|
47
|
+
export declare const getChangesIndex: () => Promise<ChangesIndex>;
|
|
48
|
+
export declare const getWorkforceStats: () => Promise<WorkforceStat[]>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A read-only client of the public Pharmasync API (`/api/v1`), for the MCP server (S8).
|
|
3
|
+
*
|
|
4
|
+
* The whole security posture of this package lives here. Every fetch goes to a **single,
|
|
5
|
+
* fixed origin** (`PHARMASYNC_API`, defaulting to the production CDN). Tool arguments filter
|
|
6
|
+
* data that has already been fetched — they never contribute to a URL, a host, or a path.
|
|
7
|
+
* So there is no SSRF surface: an argument cannot redirect a request anywhere, because
|
|
8
|
+
* arguments do not reach `fetch` at all.
|
|
9
|
+
*
|
|
10
|
+
* The data it serves is the same boundary-clean, public logistics data the CDN publishes —
|
|
11
|
+
* no keys, no store, no credentials, nothing a `/api/v1` reader could not fetch itself. The
|
|
12
|
+
* MCP server is a convenience layer over the open contract, not a privileged path to it.
|
|
13
|
+
*/
|
|
14
|
+
import { ARTEFACT_SCHEMAS, ChangeSegmentSchema, } from './schemas.js';
|
|
15
|
+
import { PACKAGE_VERSION } from './version.js';
|
|
16
|
+
/** The one origin every request goes to. A URL like `https://pharmasync.xyz/api/v1`. */
|
|
17
|
+
export const API_ORIGIN = (process.env.PHARMASYNC_API ?? 'https://pharmasync.xyz/api/v1').replace(/\/+$/, '');
|
|
18
|
+
/** The scheme+host the whole package is pinned to — the SSRF allow-list, of size one. */
|
|
19
|
+
const ORIGIN_ROOT = new URL(API_ORIGIN).origin;
|
|
20
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
21
|
+
/**
|
|
22
|
+
* Fetch a fixed artefact path relative to the API base, validate it, or throw.
|
|
23
|
+
*
|
|
24
|
+
* `relPath` is a key of `ARTEFACT_SCHEMAS` — a hard-coded literal, never derived from a tool
|
|
25
|
+
* argument. A failed fetch throws rather than returning stale or empty data, and so does a body
|
|
26
|
+
* that does not match the published schema: a tool that cannot read the contract says so, it does
|
|
27
|
+
* not hand an agent a shape it was never promised.
|
|
28
|
+
*/
|
|
29
|
+
export const getArtefact = async (relPath) => {
|
|
30
|
+
const url = `${API_ORIGIN}/${relPath}`;
|
|
31
|
+
return parseArtefact(ARTEFACT_SCHEMAS[relPath], await fetchJson(url), url);
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Fetch a segment listed in the change feed's own index (`segments[].path`), guarded.
|
|
35
|
+
*
|
|
36
|
+
* The path comes from *our own* published index, not from a tool argument — but it is still
|
|
37
|
+
* validated: it must be an absolute path under `/api/v1/changes/`, and the resolved URL must
|
|
38
|
+
* land on the pinned host. That rejects a protocol-relative `//evil.com/…` from ever being
|
|
39
|
+
* followed, even if a corrupted index somehow carried one. Defense in depth, per §2.3.
|
|
40
|
+
*/
|
|
41
|
+
export const getChangeSegment = async (pathFromRoot) => {
|
|
42
|
+
if (!/^\/api\/v1\/changes\/[A-Za-z0-9._-]+$/.test(pathFromRoot)) {
|
|
43
|
+
throw new Error(`refusing to fetch an unexpected change-segment path: ${pathFromRoot}`);
|
|
44
|
+
}
|
|
45
|
+
const url = new URL(pathFromRoot, `${ORIGIN_ROOT}/`);
|
|
46
|
+
if (url.origin !== ORIGIN_ROOT) {
|
|
47
|
+
throw new Error(`change-segment path resolved off-origin (${url.origin}); refusing`);
|
|
48
|
+
}
|
|
49
|
+
return parseArtefact(ChangeSegmentSchema, await fetchJson(url.toString()), url.toString());
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Validate a fetched body against its schema, or throw a message a tool can return verbatim.
|
|
53
|
+
*
|
|
54
|
+
* At most three issues are named, each with its JSON path, so an agent (or a human reading the
|
|
55
|
+
* tool error) can see *what* drifted without the message growing with the size of the corpus.
|
|
56
|
+
*/
|
|
57
|
+
export const parseArtefact = (schema, body, url) => {
|
|
58
|
+
const result = schema.safeParse(body);
|
|
59
|
+
if (result.success)
|
|
60
|
+
return result.data;
|
|
61
|
+
const issues = result.error.issues;
|
|
62
|
+
const named = issues
|
|
63
|
+
.slice(0, 3)
|
|
64
|
+
.map((issue) => `${issue.path.length > 0 ? issue.path.join('.') : '(root)'}: ${issue.message}`)
|
|
65
|
+
.join('; ');
|
|
66
|
+
const more = issues.length > 3 ? ` (+${String(issues.length - 3)} more)` : '';
|
|
67
|
+
throw new Error(`GET ${url} returned a body that does not match the published schema — ${named}${more}`);
|
|
68
|
+
};
|
|
69
|
+
const fetchJson = async (url) => {
|
|
70
|
+
const response = await fetch(url, {
|
|
71
|
+
headers: { accept: 'application/json', 'user-agent': `pharmasync-mcp/${PACKAGE_VERSION} (+https://pharmasync.xyz)` },
|
|
72
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
73
|
+
});
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
throw new Error(`GET ${url} -> HTTP ${String(response.status)}`);
|
|
76
|
+
}
|
|
77
|
+
// `unknown`, deliberately: nothing reads this until `parseArtefact` has validated it.
|
|
78
|
+
const body = await response.json();
|
|
79
|
+
return body;
|
|
80
|
+
};
|
|
81
|
+
export const getProducts = () => getArtefact('products.json');
|
|
82
|
+
export const getTiers = () => getArtefact('tiers.json');
|
|
83
|
+
export const getEvents = () => getArtefact('events.json');
|
|
84
|
+
export const getChangesIndex = () => getArtefact('changes/index.json');
|
|
85
|
+
export const getWorkforceStats = () => getArtefact('workforce/stats.json');
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { API_ORIGIN, getChangeSegment, getChangesIndex, getEvents, getProducts, getTiers, getWorkforceStats, } from './api.js';
|
|
6
|
+
import { MISSING_SELECTOR_MESSAGE, concessionsFor, currentShortages, filterWorkforceStats, hasSelector, lookupProducts, segmentsToFetch, } from './query.js';
|
|
7
|
+
import { TIERS, WORKFORCE_METRICS } from './schemas.js';
|
|
8
|
+
import { PACKAGE_VERSION } from './version.js';
|
|
9
|
+
const ok = (value) => ({
|
|
10
|
+
content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
11
|
+
});
|
|
12
|
+
const fail = (message) => ({
|
|
13
|
+
content: [{ type: 'text', text: `error: ${message}` }],
|
|
14
|
+
isError: true,
|
|
15
|
+
});
|
|
16
|
+
/** Wrap a tool body so a fetch/parse failure becomes an honest tool error, never a crash. */
|
|
17
|
+
const guard = async (run) => {
|
|
18
|
+
try {
|
|
19
|
+
return await run();
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
return fail(error.message);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const PRODUCT_SELECTOR = {
|
|
26
|
+
code: z.string().optional().describe('An exact dm+d code (VMP identifier), e.g. "42381011000001106".'),
|
|
27
|
+
name: z.string().optional().describe('A case-insensitive substring of the medicine name, e.g. "ramipril".'),
|
|
28
|
+
};
|
|
29
|
+
// Read from package.json (`version.ts`) — previously restated here by hand.
|
|
30
|
+
const server = new McpServer({ name: 'pharmasync', version: PACKAGE_VERSION });
|
|
31
|
+
server.registerTool('lookup_product', {
|
|
32
|
+
title: 'Look up a medicine',
|
|
33
|
+
description: 'Look up a UK medicine by exact dm+d code or by a substring of its name. Returns the dm+d ' +
|
|
34
|
+
'product, its current severity tier (the highest government act in force, with citation), and ' +
|
|
35
|
+
'its supply events (MSN / SSP / price concession), newest first. Provide `code` or `name`.',
|
|
36
|
+
inputSchema: PRODUCT_SELECTOR,
|
|
37
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
38
|
+
}, async ({ code, name }) => guard(async () => {
|
|
39
|
+
if (!hasSelector({ code, name }))
|
|
40
|
+
return fail(MISSING_SELECTOR_MESSAGE);
|
|
41
|
+
const [products, tiers, events] = await Promise.all([getProducts(), getTiers(), getEvents()]);
|
|
42
|
+
const results = lookupProducts(products, tiers, events, { code, name });
|
|
43
|
+
return ok({ query: { code, name }, count: results.length, results });
|
|
44
|
+
}));
|
|
45
|
+
server.registerTool('current_shortages', {
|
|
46
|
+
title: 'Current shortages by tier',
|
|
47
|
+
description: 'List the UK medicines with a government severity tier currently in force — WATCH (a Medicine ' +
|
|
48
|
+
'Supply Notification), CONCERN (a restricted-export designation) or CRITICAL (a Serious Shortage ' +
|
|
49
|
+
'Protocol) — ranked highest first, each with the citing act and its date. Optionally filter to one ' +
|
|
50
|
+
'tier. Every tier is a citation of a public act, reproducible without trusting Pharmasync.',
|
|
51
|
+
inputSchema: {
|
|
52
|
+
tier: z
|
|
53
|
+
.enum(TIERS)
|
|
54
|
+
.optional()
|
|
55
|
+
.describe('Restrict to a single tier. Omit for all tiers.'),
|
|
56
|
+
},
|
|
57
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
58
|
+
}, async ({ tier }) => guard(async () => {
|
|
59
|
+
const [tiers, products] = await Promise.all([getTiers(), getProducts()]);
|
|
60
|
+
const rows = currentShortages(tiers, products, tier);
|
|
61
|
+
return ok({ tier_filter: tier ?? null, count: rows.length, shortages: rows });
|
|
62
|
+
}));
|
|
63
|
+
server.registerTool('changes_since', {
|
|
64
|
+
title: 'Supply changes since a cursor',
|
|
65
|
+
description: 'Return detected supply changes from the delta feed. Pass the `cursor` you last stored to get only ' +
|
|
66
|
+
'newer changes; omit it for the full feed. Each response includes `latest_cursor` — store it and ' +
|
|
67
|
+
'pass it next time. The feed is not retrospective: events predating change detection carry no change ' +
|
|
68
|
+
'record (see the feed limitations).',
|
|
69
|
+
inputSchema: {
|
|
70
|
+
cursor: z
|
|
71
|
+
.string()
|
|
72
|
+
.optional()
|
|
73
|
+
.describe('The `latest_cursor` from a previous call (e.g. "2026-08"). Omit for all changes.'),
|
|
74
|
+
},
|
|
75
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
76
|
+
}, async ({ cursor }) => guard(async () => {
|
|
77
|
+
const index = await getChangesIndex();
|
|
78
|
+
const segments = segmentsToFetch(index, cursor);
|
|
79
|
+
const batches = await Promise.all(segments.map((s) => getChangeSegment(s.path)));
|
|
80
|
+
const changes = batches.flat();
|
|
81
|
+
return ok({
|
|
82
|
+
requested_cursor: cursor ?? null,
|
|
83
|
+
latest_cursor: index.latest_cursor,
|
|
84
|
+
cursor_protocol: index.cursor_protocol,
|
|
85
|
+
limitations: index.limitations,
|
|
86
|
+
count: changes.length,
|
|
87
|
+
changes,
|
|
88
|
+
});
|
|
89
|
+
}));
|
|
90
|
+
server.registerTool('concessions_for', {
|
|
91
|
+
title: 'Price concessions for a medicine',
|
|
92
|
+
description: 'Return the price-concession events for a medicine, by exact dm+d code or a substring of its name, ' +
|
|
93
|
+
'newest first. A concession is a financial-exposure and market-stress signal, published as the ' +
|
|
94
|
+
'source states it. Provide `code` or `name`.',
|
|
95
|
+
inputSchema: PRODUCT_SELECTOR,
|
|
96
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
97
|
+
}, async ({ code, name }) => guard(async () => {
|
|
98
|
+
if (!hasSelector({ code, name }))
|
|
99
|
+
return fail(MISSING_SELECTOR_MESSAGE);
|
|
100
|
+
const [products, events] = await Promise.all([getProducts(), getEvents()]);
|
|
101
|
+
const rows = concessionsFor(products, events, { code, name });
|
|
102
|
+
return ok({ query: { code, name }, count: rows.length, concessions: rows });
|
|
103
|
+
}));
|
|
104
|
+
server.registerTool('workforce_stats', {
|
|
105
|
+
title: 'The Workforce Panel ledger',
|
|
106
|
+
description: 'The second supply chain: medicines and the people who dispense them. Returns published ' +
|
|
107
|
+
'workforce indicators — vacancy rate by role and region, GPhC registrant counts, pharmacy ' +
|
|
108
|
+
'network change by region, and shortage designation status — newest period first. Optionally ' +
|
|
109
|
+
'filter by metric, role and/or region (filters combine; omit all for the full ledger). Not a ' +
|
|
110
|
+
'jobs board: no listings, no applications, no salary benchmarking — observed aggregate data ' +
|
|
111
|
+
'only, provenance on every row.',
|
|
112
|
+
inputSchema: {
|
|
113
|
+
metric: z
|
|
114
|
+
.enum(WORKFORCE_METRICS)
|
|
115
|
+
.optional()
|
|
116
|
+
.describe('Restrict to one indicator. Omit for every metric currently populated.'),
|
|
117
|
+
role: z.string().optional().describe('e.g. "PHARMACIST" or "PHARMACY_TECHNICIAN". Omit for every role.'),
|
|
118
|
+
region: z
|
|
119
|
+
.string()
|
|
120
|
+
.optional()
|
|
121
|
+
.describe('An exact ICS name (vacancy rate) or Health-and-Wellbeing-Board name (pharmacy count / network change). Omit for every region.'),
|
|
122
|
+
},
|
|
123
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
124
|
+
}, async ({ metric, role, region }) => guard(async () => {
|
|
125
|
+
const stats = await getWorkforceStats();
|
|
126
|
+
const rows = filterWorkforceStats(stats, { metric, role, region });
|
|
127
|
+
return ok({ query: { metric: metric ?? null, role: role ?? null, region: region ?? null }, count: rows.length, stats: rows });
|
|
128
|
+
}));
|
|
129
|
+
const main = async () => {
|
|
130
|
+
const transport = new StdioServerTransport();
|
|
131
|
+
await server.connect(transport);
|
|
132
|
+
// stderr only — stdout is the protocol channel.
|
|
133
|
+
process.stderr.write(`pharmasync MCP server ready — reading ${API_ORIGIN}\n`);
|
|
134
|
+
};
|
|
135
|
+
main().catch((error) => {
|
|
136
|
+
process.stderr.write(`pharmasync MCP server failed to start: ${error.message}\n`);
|
|
137
|
+
process.exitCode = 1;
|
|
138
|
+
});
|
package/dist/query.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ChangeSegmentRef, ChangesIndex, DmdProduct, SupplyEvent, Tier, WorkforceStat } from './api.js';
|
|
2
|
+
/**
|
|
3
|
+
* Pure query logic for the four MCP tools.
|
|
4
|
+
*
|
|
5
|
+
* Every function here takes already-fetched artefact arrays plus the tool arguments and
|
|
6
|
+
* returns a plain result — no I/O, no fetch, no MCP. That is deliberate: it keeps the
|
|
7
|
+
* matching, filtering and ranking testable in isolation, and it keeps `index.ts` a thin
|
|
8
|
+
* fetch-then-format wrapper. Arguments select and filter published data; they never reach
|
|
9
|
+
* a URL (see `api.ts`).
|
|
10
|
+
*/
|
|
11
|
+
/** Cap on rows returned by name-search tools, so a broad term can't return the whole corpus. */
|
|
12
|
+
export declare const RESULT_CAP = 25;
|
|
13
|
+
export interface ProductSelector {
|
|
14
|
+
readonly code?: string | undefined;
|
|
15
|
+
readonly name?: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
/** Whether a selector names anything — `code` or `name` with non-whitespace content. */
|
|
18
|
+
export declare const hasSelector: (selector: ProductSelector) => boolean;
|
|
19
|
+
/** The refusal both product-selecting tools return when neither `code` nor `name` was given. */
|
|
20
|
+
export declare const MISSING_SELECTOR_MESSAGE = "provide either `code` (exact dm+d code) or `name` (substring of the medicine name)";
|
|
21
|
+
/**
|
|
22
|
+
* Products matching an exact dm+d code, or a case-insensitive substring of the name.
|
|
23
|
+
*
|
|
24
|
+
* Code match is exact and unbounded (a code identifies at most one product). Name match is a
|
|
25
|
+
* substring and capped, because a short term ("sol") would otherwise sweep the corpus. Neither
|
|
26
|
+
* branch builds a URL — the argument only ever filters the array already in memory.
|
|
27
|
+
*/
|
|
28
|
+
export declare const matchProducts: (products: readonly DmdProduct[], selector: ProductSelector) => DmdProduct[];
|
|
29
|
+
export interface ProductLookup {
|
|
30
|
+
readonly product: DmdProduct;
|
|
31
|
+
/** The highest tier in force, or null when no rung applies (§7.2 — a concession is a signal, not a tier). */
|
|
32
|
+
readonly tier: Tier | null;
|
|
33
|
+
/** This product's supply events, newest first. */
|
|
34
|
+
readonly events: readonly SupplyEvent[];
|
|
35
|
+
}
|
|
36
|
+
/** `lookup_product`: a product plus its tier and its events. */
|
|
37
|
+
export declare const lookupProducts: (products: readonly DmdProduct[], tiers: readonly Tier[], events: readonly SupplyEvent[], selector: ProductSelector) => ProductLookup[];
|
|
38
|
+
export interface ShortageRow extends Tier {
|
|
39
|
+
/** The dm+d product name, when the corpus references it; null otherwise. */
|
|
40
|
+
readonly name: string | null;
|
|
41
|
+
}
|
|
42
|
+
/** `current_shortages`: the tier ladder, optionally filtered, joined with names, ranked highest first. */
|
|
43
|
+
export declare const currentShortages: (tiers: readonly Tier[], products: readonly DmdProduct[], tierFilter?: Tier["tier"]) => ShortageRow[];
|
|
44
|
+
export interface ConcessionRow extends SupplyEvent {
|
|
45
|
+
readonly name: string | null;
|
|
46
|
+
}
|
|
47
|
+
/** `concessions_for`: the price-concession events for the matched product(s), newest first. */
|
|
48
|
+
export declare const concessionsFor: (products: readonly DmdProduct[], events: readonly SupplyEvent[], selector: ProductSelector) => ConcessionRow[];
|
|
49
|
+
export interface WorkforceStatSelector {
|
|
50
|
+
readonly metric?: WorkforceStat['metric'] | undefined;
|
|
51
|
+
readonly role?: string | undefined;
|
|
52
|
+
readonly region?: string | undefined;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* `workforce_stats`: the Workforce Panel ledger, optionally filtered by metric, role and/or
|
|
56
|
+
* region (all three combine with AND when given). Every filter here is an exact match against a
|
|
57
|
+
* small, closed or near-closed vocabulary (seven metrics; two roles; a stable-ish list of ICS/
|
|
58
|
+
* Health-and-Wellbeing-Board names) — unlike `matchProducts`' name substring search over an
|
|
59
|
+
* open-ended medicine corpus, there is no plausible query shape here that could sweep an
|
|
60
|
+
* unbounded result set, so this applies no `RESULT_CAP`. Newest period first, for a stable order
|
|
61
|
+
* a machine consumer can rely on.
|
|
62
|
+
*/
|
|
63
|
+
export declare const filterWorkforceStats: (rows: readonly WorkforceStat[], selector?: WorkforceStatSelector) => WorkforceStat[];
|
|
64
|
+
/**
|
|
65
|
+
* `changes_since`: the segments to fetch for a given cursor.
|
|
66
|
+
*
|
|
67
|
+
* The feed is a cursor-in-a-published-list (D-045): fetch every segment whose `cursor` is
|
|
68
|
+
* ≥ the last cursor stored, ascending. Cursors are lexically ordered by construction
|
|
69
|
+
* (`2026-08`), so a string compare is the order. With no cursor, every segment is returned.
|
|
70
|
+
*/
|
|
71
|
+
export declare const segmentsToFetch: (index: ChangesIndex, cursor?: string) => ChangeSegmentRef[];
|
package/dist/query.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure query logic for the four MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* Every function here takes already-fetched artefact arrays plus the tool arguments and
|
|
5
|
+
* returns a plain result — no I/O, no fetch, no MCP. That is deliberate: it keeps the
|
|
6
|
+
* matching, filtering and ranking testable in isolation, and it keeps `index.ts` a thin
|
|
7
|
+
* fetch-then-format wrapper. Arguments select and filter published data; they never reach
|
|
8
|
+
* a URL (see `api.ts`).
|
|
9
|
+
*/
|
|
10
|
+
/** Cap on rows returned by name-search tools, so a broad term can't return the whole corpus. */
|
|
11
|
+
export const RESULT_CAP = 25;
|
|
12
|
+
const norm = (value) => value.trim().toLowerCase();
|
|
13
|
+
/** Newest first, by published date then id — a stable order for a machine consumer. */
|
|
14
|
+
const byPublishedDesc = (a, b) => a.source_published_at !== b.source_published_at
|
|
15
|
+
? a.source_published_at < b.source_published_at
|
|
16
|
+
? 1
|
|
17
|
+
: -1
|
|
18
|
+
: a.id < b.id
|
|
19
|
+
? 1
|
|
20
|
+
: a.id > b.id
|
|
21
|
+
? -1
|
|
22
|
+
: 0;
|
|
23
|
+
/** Whether a selector names anything — `code` or `name` with non-whitespace content. */
|
|
24
|
+
export const hasSelector = (selector) => (selector.code ?? '').trim() !== '' || (selector.name ?? '').trim() !== '';
|
|
25
|
+
/** The refusal both product-selecting tools return when neither `code` nor `name` was given. */
|
|
26
|
+
export const MISSING_SELECTOR_MESSAGE = 'provide either `code` (exact dm+d code) or `name` (substring of the medicine name)';
|
|
27
|
+
/**
|
|
28
|
+
* Products matching an exact dm+d code, or a case-insensitive substring of the name.
|
|
29
|
+
*
|
|
30
|
+
* Code match is exact and unbounded (a code identifies at most one product). Name match is a
|
|
31
|
+
* substring and capped, because a short term ("sol") would otherwise sweep the corpus. Neither
|
|
32
|
+
* branch builds a URL — the argument only ever filters the array already in memory.
|
|
33
|
+
*/
|
|
34
|
+
export const matchProducts = (products, selector) => {
|
|
35
|
+
const code = selector.code?.trim();
|
|
36
|
+
if (code !== undefined && code.length > 0) {
|
|
37
|
+
return products.filter((p) => p.dmd_code === code);
|
|
38
|
+
}
|
|
39
|
+
const name = selector.name?.trim();
|
|
40
|
+
if (name !== undefined && name.length > 0) {
|
|
41
|
+
const needle = norm(name);
|
|
42
|
+
return products.filter((p) => norm(p.name).includes(needle)).slice(0, RESULT_CAP);
|
|
43
|
+
}
|
|
44
|
+
return [];
|
|
45
|
+
};
|
|
46
|
+
/** `lookup_product`: a product plus its tier and its events. */
|
|
47
|
+
export const lookupProducts = (products, tiers, events, selector) => {
|
|
48
|
+
const tierByCode = new Map(tiers.map((t) => [t.dmd_code, t]));
|
|
49
|
+
const eventsByCode = new Map();
|
|
50
|
+
for (const event of events) {
|
|
51
|
+
const bucket = eventsByCode.get(event.dmd_code);
|
|
52
|
+
if (bucket === undefined)
|
|
53
|
+
eventsByCode.set(event.dmd_code, [event]);
|
|
54
|
+
else
|
|
55
|
+
bucket.push(event);
|
|
56
|
+
}
|
|
57
|
+
return matchProducts(products, selector).map((product) => ({
|
|
58
|
+
product,
|
|
59
|
+
tier: tierByCode.get(product.dmd_code) ?? null,
|
|
60
|
+
events: [...(eventsByCode.get(product.dmd_code) ?? [])].sort(byPublishedDesc),
|
|
61
|
+
}));
|
|
62
|
+
};
|
|
63
|
+
const TIER_RANK = { CRITICAL: 3, CONCERN: 2, WATCH: 1 };
|
|
64
|
+
/** `current_shortages`: the tier ladder, optionally filtered, joined with names, ranked highest first. */
|
|
65
|
+
export const currentShortages = (tiers, products, tierFilter) => {
|
|
66
|
+
const nameByCode = new Map(products.map((p) => [p.dmd_code, p.name]));
|
|
67
|
+
return tiers
|
|
68
|
+
.filter((t) => tierFilter === undefined || t.tier === tierFilter)
|
|
69
|
+
.map((t) => ({ ...t, name: nameByCode.get(t.dmd_code) ?? null }))
|
|
70
|
+
.sort((a, b) => TIER_RANK[b.tier] - TIER_RANK[a.tier] || (a.dmd_code < b.dmd_code ? -1 : a.dmd_code > b.dmd_code ? 1 : 0));
|
|
71
|
+
};
|
|
72
|
+
/** `concessions_for`: the price-concession events for the matched product(s), newest first. */
|
|
73
|
+
export const concessionsFor = (products, events, selector) => {
|
|
74
|
+
const codes = new Set(matchProducts(products, selector).map((p) => p.dmd_code));
|
|
75
|
+
const nameByCode = new Map(products.map((p) => [p.dmd_code, p.name]));
|
|
76
|
+
return events
|
|
77
|
+
.filter((e) => e.kind === 'CONCESSION' && codes.has(e.dmd_code))
|
|
78
|
+
.map((e) => ({ ...e, name: nameByCode.get(e.dmd_code) ?? null }))
|
|
79
|
+
.sort(byPublishedDesc);
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* `workforce_stats`: the Workforce Panel ledger, optionally filtered by metric, role and/or
|
|
83
|
+
* region (all three combine with AND when given). Every filter here is an exact match against a
|
|
84
|
+
* small, closed or near-closed vocabulary (seven metrics; two roles; a stable-ish list of ICS/
|
|
85
|
+
* Health-and-Wellbeing-Board names) — unlike `matchProducts`' name substring search over an
|
|
86
|
+
* open-ended medicine corpus, there is no plausible query shape here that could sweep an
|
|
87
|
+
* unbounded result set, so this applies no `RESULT_CAP`. Newest period first, for a stable order
|
|
88
|
+
* a machine consumer can rely on.
|
|
89
|
+
*/
|
|
90
|
+
export const filterWorkforceStats = (rows, selector = {}) => rows
|
|
91
|
+
.filter((r) => selector.metric === undefined || r.metric === selector.metric)
|
|
92
|
+
.filter((r) => selector.role === undefined || r.role === selector.role)
|
|
93
|
+
.filter((r) => selector.region === undefined || r.region === selector.region)
|
|
94
|
+
.sort((a, b) => (a.period < b.period ? 1 : a.period > b.period ? -1 : a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
95
|
+
/**
|
|
96
|
+
* `changes_since`: the segments to fetch for a given cursor.
|
|
97
|
+
*
|
|
98
|
+
* The feed is a cursor-in-a-published-list (D-045): fetch every segment whose `cursor` is
|
|
99
|
+
* ≥ the last cursor stored, ascending. Cursors are lexically ordered by construction
|
|
100
|
+
* (`2026-08`), so a string compare is the order. With no cursor, every segment is returned.
|
|
101
|
+
*/
|
|
102
|
+
export const segmentsToFetch = (index, cursor) => {
|
|
103
|
+
const ordered = [...index.segments].sort((a, b) => a.cursor < b.cursor ? -1 : a.cursor > b.cursor ? 1 : 0);
|
|
104
|
+
return cursor === undefined ? ordered : ordered.filter((s) => s.cursor >= cursor);
|
|
105
|
+
};
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Runtime schemas for every `/api/v1` artefact this package reads.
|
|
4
|
+
*
|
|
5
|
+
* **The source of truth is the pipeline, not this file.** Each entity schema mirrors the JSON
|
|
6
|
+
* Schema document the pipeline publishes at `/api/v1/schema/<Name>.json` (generated from the same
|
|
7
|
+
* declaration its ingestion gate enforces — `pipeline/src/artefacts/json-schema.ts`). The CI
|
|
8
|
+
* contract step (`test/contract-cli.ts`, run in check.yml against freshly built artefacts) fails
|
|
9
|
+
* if a property name, `required` entry, type or enum here drifts from that published document —
|
|
10
|
+
* so this file cannot silently fall out of step with the contract it claims to read.
|
|
11
|
+
*
|
|
12
|
+
* Two deliberate choices:
|
|
13
|
+
* * `z.looseObject`, not `z.strictObject`. The pipeline emits `additionalProperties: false`, but
|
|
14
|
+
* the frozen-contract rule (PROJECT_RULES §3) allows *additive* changes within `/v1`. A client
|
|
15
|
+
* that rejected an added field would turn a non-breaking change into an outage. Unknown keys
|
|
16
|
+
* pass through unchanged, exactly as the tool output did before validation existed.
|
|
17
|
+
* * `format` (date, date-time, uri) is not enforced. The pipeline's gate enforces it before
|
|
18
|
+
* persistence; re-checking it here would make a formatting nit an MCP outage. Types, enums
|
|
19
|
+
* and required-ness — the things a tool's logic actually depends on — are enforced.
|
|
20
|
+
*
|
|
21
|
+
* Types are derived from these schemas (`z.infer`), so the TypeScript shape and the runtime
|
|
22
|
+
* check are one declaration rather than two that can disagree.
|
|
23
|
+
*/
|
|
24
|
+
/** The three rungs of the tier ladder (D-009/D-051), lowest first. Shared by the schema and the tool. */
|
|
25
|
+
export declare const TIERS: readonly ["WATCH", "CONCERN", "CRITICAL"];
|
|
26
|
+
/** The Workforce Panel's indicators (S15). Shared by the schema and the tool. */
|
|
27
|
+
export declare const WORKFORCE_METRICS: readonly ["VACANCY_RATE", "REGISTRANTS", "PHARMACY_COUNT", "NET_CHANGE", "SHORTAGE_STATUS", "CONTEXT_VACANCIES", "DENSITY_PER_10K"];
|
|
28
|
+
/** A dm+d product for a referenced code (D-042). Mirrors `/api/v1/schema/DmdProduct.json`. */
|
|
29
|
+
export declare const DmdProductSchema: z.ZodObject<{
|
|
30
|
+
dmd_code: z.ZodString;
|
|
31
|
+
name: z.ZodString;
|
|
32
|
+
form: z.ZodOptional<z.ZodString>;
|
|
33
|
+
strength: z.ZodOptional<z.ZodString>;
|
|
34
|
+
retired: z.ZodBoolean;
|
|
35
|
+
dmd_release: z.ZodString;
|
|
36
|
+
}, z.core.$loose>;
|
|
37
|
+
/** The highest rung of government action in force for a medicine. Mirrors `Tier.json`. */
|
|
38
|
+
export declare const TierSchema: z.ZodObject<{
|
|
39
|
+
dmd_code: z.ZodString;
|
|
40
|
+
tier: z.ZodEnum<{
|
|
41
|
+
WATCH: "WATCH";
|
|
42
|
+
CONCERN: "CONCERN";
|
|
43
|
+
CRITICAL: "CRITICAL";
|
|
44
|
+
}>;
|
|
45
|
+
triggering_act: z.ZodString;
|
|
46
|
+
source_url: z.ZodString;
|
|
47
|
+
source_date: z.ZodString;
|
|
48
|
+
in_force_until: z.ZodOptional<z.ZodString>;
|
|
49
|
+
market_stress: z.ZodBoolean;
|
|
50
|
+
}, z.core.$loose>;
|
|
51
|
+
/** One published supply event. Mirrors `SupplyEvent.json`. */
|
|
52
|
+
export declare const SupplyEventSchema: z.ZodObject<{
|
|
53
|
+
id: z.ZodString;
|
|
54
|
+
dmd_code: z.ZodString;
|
|
55
|
+
kind: z.ZodEnum<{
|
|
56
|
+
MSN: "MSN";
|
|
57
|
+
SSP: "SSP";
|
|
58
|
+
CONCESSION: "CONCESSION";
|
|
59
|
+
}>;
|
|
60
|
+
status: z.ZodString;
|
|
61
|
+
detail: z.ZodOptional<z.ZodString>;
|
|
62
|
+
reason_code: z.ZodOptional<z.ZodEnum<{
|
|
63
|
+
manufacturing: "manufacturing";
|
|
64
|
+
api_supply: "api_supply";
|
|
65
|
+
discontinuation: "discontinuation";
|
|
66
|
+
shipping: "shipping";
|
|
67
|
+
demand: "demand";
|
|
68
|
+
unstated: "unstated";
|
|
69
|
+
}>>;
|
|
70
|
+
nations: z.ZodArray<z.ZodEnum<{
|
|
71
|
+
ENG: "ENG";
|
|
72
|
+
SCO: "SCO";
|
|
73
|
+
WAL: "WAL";
|
|
74
|
+
NIR: "NIR";
|
|
75
|
+
}>>;
|
|
76
|
+
effective_from: z.ZodString;
|
|
77
|
+
effective_from_basis: z.ZodEnum<{
|
|
78
|
+
published: "published";
|
|
79
|
+
first_observed: "first_observed";
|
|
80
|
+
}>;
|
|
81
|
+
effective_to: z.ZodOptional<z.ZodString>;
|
|
82
|
+
source_url: z.ZodString;
|
|
83
|
+
source_published_at: z.ZodString;
|
|
84
|
+
source_published_at_basis: z.ZodEnum<{
|
|
85
|
+
published: "published";
|
|
86
|
+
first_observed: "first_observed";
|
|
87
|
+
http_last_modified: "http_last_modified";
|
|
88
|
+
}>;
|
|
89
|
+
first_seen_at: z.ZodString;
|
|
90
|
+
}, z.core.$loose>;
|
|
91
|
+
/** A detected change in the delta feed. Mirrors `ChangeRecord.json`. */
|
|
92
|
+
export declare const ChangeRecordSchema: z.ZodObject<{
|
|
93
|
+
id: z.ZodString;
|
|
94
|
+
kind: z.ZodEnum<{
|
|
95
|
+
SUPPLY: "SUPPLY";
|
|
96
|
+
WORKFORCE: "WORKFORCE";
|
|
97
|
+
}>;
|
|
98
|
+
dmd_code: z.ZodOptional<z.ZodString>;
|
|
99
|
+
change_type: z.ZodEnum<{
|
|
100
|
+
NEW: "NEW";
|
|
101
|
+
UPDATED: "UPDATED";
|
|
102
|
+
RESOLVED: "RESOLVED";
|
|
103
|
+
EXPIRED: "EXPIRED";
|
|
104
|
+
}>;
|
|
105
|
+
event_id: z.ZodOptional<z.ZodString>;
|
|
106
|
+
detected_at: z.ZodString;
|
|
107
|
+
headline: z.ZodString;
|
|
108
|
+
}, z.core.$loose>;
|
|
109
|
+
/** One row of the Workforce Panel's ledger (S15). Mirrors `WorkforceStat.json`. */
|
|
110
|
+
export declare const WorkforceStatSchema: z.ZodObject<{
|
|
111
|
+
id: z.ZodString;
|
|
112
|
+
metric: z.ZodEnum<{
|
|
113
|
+
VACANCY_RATE: "VACANCY_RATE";
|
|
114
|
+
REGISTRANTS: "REGISTRANTS";
|
|
115
|
+
PHARMACY_COUNT: "PHARMACY_COUNT";
|
|
116
|
+
NET_CHANGE: "NET_CHANGE";
|
|
117
|
+
SHORTAGE_STATUS: "SHORTAGE_STATUS";
|
|
118
|
+
CONTEXT_VACANCIES: "CONTEXT_VACANCIES";
|
|
119
|
+
DENSITY_PER_10K: "DENSITY_PER_10K";
|
|
120
|
+
}>;
|
|
121
|
+
role: z.ZodOptional<z.ZodString>;
|
|
122
|
+
region: z.ZodOptional<z.ZodString>;
|
|
123
|
+
period: z.ZodString;
|
|
124
|
+
value: z.ZodNumber;
|
|
125
|
+
unit: z.ZodString;
|
|
126
|
+
nhwa_code: z.ZodOptional<z.ZodString>;
|
|
127
|
+
source_url: z.ZodString;
|
|
128
|
+
source_published_at: z.ZodString;
|
|
129
|
+
source_published_at_basis: z.ZodEnum<{
|
|
130
|
+
published: "published";
|
|
131
|
+
first_observed: "first_observed";
|
|
132
|
+
}>;
|
|
133
|
+
first_seen_at: z.ZodString;
|
|
134
|
+
}, z.core.$loose>;
|
|
135
|
+
/**
|
|
136
|
+
* One entry of the change feed's index. The pipeline publishes no JSON Schema document for the
|
|
137
|
+
* index (it is described in `openapi.json`), so this mirrors `FeedSegment` in
|
|
138
|
+
* `pipeline/src/artefacts/changes.ts` instead.
|
|
139
|
+
*/
|
|
140
|
+
export declare const ChangeSegmentRefSchema: z.ZodObject<{
|
|
141
|
+
cursor: z.ZodString;
|
|
142
|
+
path: z.ZodString;
|
|
143
|
+
count: z.ZodNumber;
|
|
144
|
+
closed: z.ZodBoolean;
|
|
145
|
+
from: z.ZodString;
|
|
146
|
+
to: z.ZodString;
|
|
147
|
+
}, z.core.$loose>;
|
|
148
|
+
/** `/api/v1/changes/index.json`. Mirrors `ChangeFeedIndex` in `pipeline/src/artefacts/changes.ts`. */
|
|
149
|
+
export declare const ChangesIndexSchema: z.ZodObject<{
|
|
150
|
+
api_version: z.ZodString;
|
|
151
|
+
generated_at: z.ZodString;
|
|
152
|
+
latest_cursor: z.ZodNullable<z.ZodString>;
|
|
153
|
+
total_changes: z.ZodNumber;
|
|
154
|
+
segments: z.ZodArray<z.ZodObject<{
|
|
155
|
+
cursor: z.ZodString;
|
|
156
|
+
path: z.ZodString;
|
|
157
|
+
count: z.ZodNumber;
|
|
158
|
+
closed: z.ZodBoolean;
|
|
159
|
+
from: z.ZodString;
|
|
160
|
+
to: z.ZodString;
|
|
161
|
+
}, z.core.$loose>>;
|
|
162
|
+
cursor_protocol: z.ZodArray<z.ZodString>;
|
|
163
|
+
limitations: z.ZodArray<z.ZodString>;
|
|
164
|
+
}, z.core.$loose>;
|
|
165
|
+
export type Tier = z.infer<typeof TierSchema>;
|
|
166
|
+
export type TierLevel = (typeof TIERS)[number];
|
|
167
|
+
export type DmdProduct = z.infer<typeof DmdProductSchema>;
|
|
168
|
+
export type SupplyEvent = z.infer<typeof SupplyEventSchema>;
|
|
169
|
+
export type ChangeRecord = z.infer<typeof ChangeRecordSchema>;
|
|
170
|
+
export type WorkforceStat = z.infer<typeof WorkforceStatSchema>;
|
|
171
|
+
export type WorkforceMetric = (typeof WORKFORCE_METRICS)[number];
|
|
172
|
+
export type ChangeSegmentRef = z.infer<typeof ChangeSegmentRefSchema>;
|
|
173
|
+
export type ChangesIndex = z.infer<typeof ChangesIndexSchema>;
|
|
174
|
+
/**
|
|
175
|
+
* The entity schemas that have a published JSON Schema counterpart, keyed by its title — the
|
|
176
|
+
* registry the contract check walks. Adding a schema here without a published document (or the
|
|
177
|
+
* reverse, for a document this package reads) fails that check.
|
|
178
|
+
*/
|
|
179
|
+
export declare const PUBLISHED_ENTITY_SCHEMAS: {
|
|
180
|
+
readonly DmdProduct: z.ZodObject<{
|
|
181
|
+
dmd_code: z.ZodString;
|
|
182
|
+
name: z.ZodString;
|
|
183
|
+
form: z.ZodOptional<z.ZodString>;
|
|
184
|
+
strength: z.ZodOptional<z.ZodString>;
|
|
185
|
+
retired: z.ZodBoolean;
|
|
186
|
+
dmd_release: z.ZodString;
|
|
187
|
+
}, z.core.$loose>;
|
|
188
|
+
readonly Tier: z.ZodObject<{
|
|
189
|
+
dmd_code: z.ZodString;
|
|
190
|
+
tier: z.ZodEnum<{
|
|
191
|
+
WATCH: "WATCH";
|
|
192
|
+
CONCERN: "CONCERN";
|
|
193
|
+
CRITICAL: "CRITICAL";
|
|
194
|
+
}>;
|
|
195
|
+
triggering_act: z.ZodString;
|
|
196
|
+
source_url: z.ZodString;
|
|
197
|
+
source_date: z.ZodString;
|
|
198
|
+
in_force_until: z.ZodOptional<z.ZodString>;
|
|
199
|
+
market_stress: z.ZodBoolean;
|
|
200
|
+
}, z.core.$loose>;
|
|
201
|
+
readonly SupplyEvent: z.ZodObject<{
|
|
202
|
+
id: z.ZodString;
|
|
203
|
+
dmd_code: z.ZodString;
|
|
204
|
+
kind: z.ZodEnum<{
|
|
205
|
+
MSN: "MSN";
|
|
206
|
+
SSP: "SSP";
|
|
207
|
+
CONCESSION: "CONCESSION";
|
|
208
|
+
}>;
|
|
209
|
+
status: z.ZodString;
|
|
210
|
+
detail: z.ZodOptional<z.ZodString>;
|
|
211
|
+
reason_code: z.ZodOptional<z.ZodEnum<{
|
|
212
|
+
manufacturing: "manufacturing";
|
|
213
|
+
api_supply: "api_supply";
|
|
214
|
+
discontinuation: "discontinuation";
|
|
215
|
+
shipping: "shipping";
|
|
216
|
+
demand: "demand";
|
|
217
|
+
unstated: "unstated";
|
|
218
|
+
}>>;
|
|
219
|
+
nations: z.ZodArray<z.ZodEnum<{
|
|
220
|
+
ENG: "ENG";
|
|
221
|
+
SCO: "SCO";
|
|
222
|
+
WAL: "WAL";
|
|
223
|
+
NIR: "NIR";
|
|
224
|
+
}>>;
|
|
225
|
+
effective_from: z.ZodString;
|
|
226
|
+
effective_from_basis: z.ZodEnum<{
|
|
227
|
+
published: "published";
|
|
228
|
+
first_observed: "first_observed";
|
|
229
|
+
}>;
|
|
230
|
+
effective_to: z.ZodOptional<z.ZodString>;
|
|
231
|
+
source_url: z.ZodString;
|
|
232
|
+
source_published_at: z.ZodString;
|
|
233
|
+
source_published_at_basis: z.ZodEnum<{
|
|
234
|
+
published: "published";
|
|
235
|
+
first_observed: "first_observed";
|
|
236
|
+
http_last_modified: "http_last_modified";
|
|
237
|
+
}>;
|
|
238
|
+
first_seen_at: z.ZodString;
|
|
239
|
+
}, z.core.$loose>;
|
|
240
|
+
readonly ChangeRecord: z.ZodObject<{
|
|
241
|
+
id: z.ZodString;
|
|
242
|
+
kind: z.ZodEnum<{
|
|
243
|
+
SUPPLY: "SUPPLY";
|
|
244
|
+
WORKFORCE: "WORKFORCE";
|
|
245
|
+
}>;
|
|
246
|
+
dmd_code: z.ZodOptional<z.ZodString>;
|
|
247
|
+
change_type: z.ZodEnum<{
|
|
248
|
+
NEW: "NEW";
|
|
249
|
+
UPDATED: "UPDATED";
|
|
250
|
+
RESOLVED: "RESOLVED";
|
|
251
|
+
EXPIRED: "EXPIRED";
|
|
252
|
+
}>;
|
|
253
|
+
event_id: z.ZodOptional<z.ZodString>;
|
|
254
|
+
detected_at: z.ZodString;
|
|
255
|
+
headline: z.ZodString;
|
|
256
|
+
}, z.core.$loose>;
|
|
257
|
+
readonly WorkforceStat: z.ZodObject<{
|
|
258
|
+
id: z.ZodString;
|
|
259
|
+
metric: z.ZodEnum<{
|
|
260
|
+
VACANCY_RATE: "VACANCY_RATE";
|
|
261
|
+
REGISTRANTS: "REGISTRANTS";
|
|
262
|
+
PHARMACY_COUNT: "PHARMACY_COUNT";
|
|
263
|
+
NET_CHANGE: "NET_CHANGE";
|
|
264
|
+
SHORTAGE_STATUS: "SHORTAGE_STATUS";
|
|
265
|
+
CONTEXT_VACANCIES: "CONTEXT_VACANCIES";
|
|
266
|
+
DENSITY_PER_10K: "DENSITY_PER_10K";
|
|
267
|
+
}>;
|
|
268
|
+
role: z.ZodOptional<z.ZodString>;
|
|
269
|
+
region: z.ZodOptional<z.ZodString>;
|
|
270
|
+
period: z.ZodString;
|
|
271
|
+
value: z.ZodNumber;
|
|
272
|
+
unit: z.ZodString;
|
|
273
|
+
nhwa_code: z.ZodOptional<z.ZodString>;
|
|
274
|
+
source_url: z.ZodString;
|
|
275
|
+
source_published_at: z.ZodString;
|
|
276
|
+
source_published_at_basis: z.ZodEnum<{
|
|
277
|
+
published: "published";
|
|
278
|
+
first_observed: "first_observed";
|
|
279
|
+
}>;
|
|
280
|
+
first_seen_at: z.ZodString;
|
|
281
|
+
}, z.core.$loose>;
|
|
282
|
+
};
|
|
283
|
+
/**
|
|
284
|
+
* Every data artefact this package fetches, by path under `/api/v1`, with the schema its body
|
|
285
|
+
* must satisfy. Change segments (`changes/<cursor>.json`) are validated as `ChangeRecord[]`.
|
|
286
|
+
*/
|
|
287
|
+
export declare const ARTEFACT_SCHEMAS: {
|
|
288
|
+
readonly 'products.json': z.ZodArray<z.ZodObject<{
|
|
289
|
+
dmd_code: z.ZodString;
|
|
290
|
+
name: z.ZodString;
|
|
291
|
+
form: z.ZodOptional<z.ZodString>;
|
|
292
|
+
strength: z.ZodOptional<z.ZodString>;
|
|
293
|
+
retired: z.ZodBoolean;
|
|
294
|
+
dmd_release: z.ZodString;
|
|
295
|
+
}, z.core.$loose>>;
|
|
296
|
+
readonly 'tiers.json': z.ZodArray<z.ZodObject<{
|
|
297
|
+
dmd_code: z.ZodString;
|
|
298
|
+
tier: z.ZodEnum<{
|
|
299
|
+
WATCH: "WATCH";
|
|
300
|
+
CONCERN: "CONCERN";
|
|
301
|
+
CRITICAL: "CRITICAL";
|
|
302
|
+
}>;
|
|
303
|
+
triggering_act: z.ZodString;
|
|
304
|
+
source_url: z.ZodString;
|
|
305
|
+
source_date: z.ZodString;
|
|
306
|
+
in_force_until: z.ZodOptional<z.ZodString>;
|
|
307
|
+
market_stress: z.ZodBoolean;
|
|
308
|
+
}, z.core.$loose>>;
|
|
309
|
+
readonly 'events.json': z.ZodArray<z.ZodObject<{
|
|
310
|
+
id: z.ZodString;
|
|
311
|
+
dmd_code: z.ZodString;
|
|
312
|
+
kind: z.ZodEnum<{
|
|
313
|
+
MSN: "MSN";
|
|
314
|
+
SSP: "SSP";
|
|
315
|
+
CONCESSION: "CONCESSION";
|
|
316
|
+
}>;
|
|
317
|
+
status: z.ZodString;
|
|
318
|
+
detail: z.ZodOptional<z.ZodString>;
|
|
319
|
+
reason_code: z.ZodOptional<z.ZodEnum<{
|
|
320
|
+
manufacturing: "manufacturing";
|
|
321
|
+
api_supply: "api_supply";
|
|
322
|
+
discontinuation: "discontinuation";
|
|
323
|
+
shipping: "shipping";
|
|
324
|
+
demand: "demand";
|
|
325
|
+
unstated: "unstated";
|
|
326
|
+
}>>;
|
|
327
|
+
nations: z.ZodArray<z.ZodEnum<{
|
|
328
|
+
ENG: "ENG";
|
|
329
|
+
SCO: "SCO";
|
|
330
|
+
WAL: "WAL";
|
|
331
|
+
NIR: "NIR";
|
|
332
|
+
}>>;
|
|
333
|
+
effective_from: z.ZodString;
|
|
334
|
+
effective_from_basis: z.ZodEnum<{
|
|
335
|
+
published: "published";
|
|
336
|
+
first_observed: "first_observed";
|
|
337
|
+
}>;
|
|
338
|
+
effective_to: z.ZodOptional<z.ZodString>;
|
|
339
|
+
source_url: z.ZodString;
|
|
340
|
+
source_published_at: z.ZodString;
|
|
341
|
+
source_published_at_basis: z.ZodEnum<{
|
|
342
|
+
published: "published";
|
|
343
|
+
first_observed: "first_observed";
|
|
344
|
+
http_last_modified: "http_last_modified";
|
|
345
|
+
}>;
|
|
346
|
+
first_seen_at: z.ZodString;
|
|
347
|
+
}, z.core.$loose>>;
|
|
348
|
+
readonly 'changes/index.json': z.ZodObject<{
|
|
349
|
+
api_version: z.ZodString;
|
|
350
|
+
generated_at: z.ZodString;
|
|
351
|
+
latest_cursor: z.ZodNullable<z.ZodString>;
|
|
352
|
+
total_changes: z.ZodNumber;
|
|
353
|
+
segments: z.ZodArray<z.ZodObject<{
|
|
354
|
+
cursor: z.ZodString;
|
|
355
|
+
path: z.ZodString;
|
|
356
|
+
count: z.ZodNumber;
|
|
357
|
+
closed: z.ZodBoolean;
|
|
358
|
+
from: z.ZodString;
|
|
359
|
+
to: z.ZodString;
|
|
360
|
+
}, z.core.$loose>>;
|
|
361
|
+
cursor_protocol: z.ZodArray<z.ZodString>;
|
|
362
|
+
limitations: z.ZodArray<z.ZodString>;
|
|
363
|
+
}, z.core.$loose>;
|
|
364
|
+
readonly 'workforce/stats.json': z.ZodArray<z.ZodObject<{
|
|
365
|
+
id: z.ZodString;
|
|
366
|
+
metric: z.ZodEnum<{
|
|
367
|
+
VACANCY_RATE: "VACANCY_RATE";
|
|
368
|
+
REGISTRANTS: "REGISTRANTS";
|
|
369
|
+
PHARMACY_COUNT: "PHARMACY_COUNT";
|
|
370
|
+
NET_CHANGE: "NET_CHANGE";
|
|
371
|
+
SHORTAGE_STATUS: "SHORTAGE_STATUS";
|
|
372
|
+
CONTEXT_VACANCIES: "CONTEXT_VACANCIES";
|
|
373
|
+
DENSITY_PER_10K: "DENSITY_PER_10K";
|
|
374
|
+
}>;
|
|
375
|
+
role: z.ZodOptional<z.ZodString>;
|
|
376
|
+
region: z.ZodOptional<z.ZodString>;
|
|
377
|
+
period: z.ZodString;
|
|
378
|
+
value: z.ZodNumber;
|
|
379
|
+
unit: z.ZodString;
|
|
380
|
+
nhwa_code: z.ZodOptional<z.ZodString>;
|
|
381
|
+
source_url: z.ZodString;
|
|
382
|
+
source_published_at: z.ZodString;
|
|
383
|
+
source_published_at_basis: z.ZodEnum<{
|
|
384
|
+
published: "published";
|
|
385
|
+
first_observed: "first_observed";
|
|
386
|
+
}>;
|
|
387
|
+
first_seen_at: z.ZodString;
|
|
388
|
+
}, z.core.$loose>>;
|
|
389
|
+
};
|
|
390
|
+
export declare const ChangeSegmentSchema: z.ZodArray<z.ZodObject<{
|
|
391
|
+
id: z.ZodString;
|
|
392
|
+
kind: z.ZodEnum<{
|
|
393
|
+
SUPPLY: "SUPPLY";
|
|
394
|
+
WORKFORCE: "WORKFORCE";
|
|
395
|
+
}>;
|
|
396
|
+
dmd_code: z.ZodOptional<z.ZodString>;
|
|
397
|
+
change_type: z.ZodEnum<{
|
|
398
|
+
NEW: "NEW";
|
|
399
|
+
UPDATED: "UPDATED";
|
|
400
|
+
RESOLVED: "RESOLVED";
|
|
401
|
+
EXPIRED: "EXPIRED";
|
|
402
|
+
}>;
|
|
403
|
+
event_id: z.ZodOptional<z.ZodString>;
|
|
404
|
+
detected_at: z.ZodString;
|
|
405
|
+
headline: z.ZodString;
|
|
406
|
+
}, z.core.$loose>>;
|
|
407
|
+
export type ArtefactPath = keyof typeof ARTEFACT_SCHEMAS;
|
package/dist/schemas.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Runtime schemas for every `/api/v1` artefact this package reads.
|
|
4
|
+
*
|
|
5
|
+
* **The source of truth is the pipeline, not this file.** Each entity schema mirrors the JSON
|
|
6
|
+
* Schema document the pipeline publishes at `/api/v1/schema/<Name>.json` (generated from the same
|
|
7
|
+
* declaration its ingestion gate enforces — `pipeline/src/artefacts/json-schema.ts`). The CI
|
|
8
|
+
* contract step (`test/contract-cli.ts`, run in check.yml against freshly built artefacts) fails
|
|
9
|
+
* if a property name, `required` entry, type or enum here drifts from that published document —
|
|
10
|
+
* so this file cannot silently fall out of step with the contract it claims to read.
|
|
11
|
+
*
|
|
12
|
+
* Two deliberate choices:
|
|
13
|
+
* * `z.looseObject`, not `z.strictObject`. The pipeline emits `additionalProperties: false`, but
|
|
14
|
+
* the frozen-contract rule (PROJECT_RULES §3) allows *additive* changes within `/v1`. A client
|
|
15
|
+
* that rejected an added field would turn a non-breaking change into an outage. Unknown keys
|
|
16
|
+
* pass through unchanged, exactly as the tool output did before validation existed.
|
|
17
|
+
* * `format` (date, date-time, uri) is not enforced. The pipeline's gate enforces it before
|
|
18
|
+
* persistence; re-checking it here would make a formatting nit an MCP outage. Types, enums
|
|
19
|
+
* and required-ness — the things a tool's logic actually depends on — are enforced.
|
|
20
|
+
*
|
|
21
|
+
* Types are derived from these schemas (`z.infer`), so the TypeScript shape and the runtime
|
|
22
|
+
* check are one declaration rather than two that can disagree.
|
|
23
|
+
*/
|
|
24
|
+
/** The three rungs of the tier ladder (D-009/D-051), lowest first. Shared by the schema and the tool. */
|
|
25
|
+
export const TIERS = ['WATCH', 'CONCERN', 'CRITICAL'];
|
|
26
|
+
/** The Workforce Panel's indicators (S15). Shared by the schema and the tool. */
|
|
27
|
+
export const WORKFORCE_METRICS = [
|
|
28
|
+
'VACANCY_RATE',
|
|
29
|
+
'REGISTRANTS',
|
|
30
|
+
'PHARMACY_COUNT',
|
|
31
|
+
'NET_CHANGE',
|
|
32
|
+
'SHORTAGE_STATUS',
|
|
33
|
+
'CONTEXT_VACANCIES',
|
|
34
|
+
'DENSITY_PER_10K',
|
|
35
|
+
];
|
|
36
|
+
/** A dm+d product for a referenced code (D-042). Mirrors `/api/v1/schema/DmdProduct.json`. */
|
|
37
|
+
export const DmdProductSchema = z.looseObject({
|
|
38
|
+
dmd_code: z.string(),
|
|
39
|
+
name: z.string(),
|
|
40
|
+
form: z.string().optional(),
|
|
41
|
+
strength: z.string().optional(),
|
|
42
|
+
retired: z.boolean(),
|
|
43
|
+
dmd_release: z.string(),
|
|
44
|
+
});
|
|
45
|
+
/** The highest rung of government action in force for a medicine. Mirrors `Tier.json`. */
|
|
46
|
+
export const TierSchema = z.looseObject({
|
|
47
|
+
dmd_code: z.string(),
|
|
48
|
+
tier: z.enum(TIERS),
|
|
49
|
+
triggering_act: z.string(),
|
|
50
|
+
source_url: z.string(),
|
|
51
|
+
source_date: z.string(),
|
|
52
|
+
in_force_until: z.string().optional(),
|
|
53
|
+
market_stress: z.boolean(),
|
|
54
|
+
});
|
|
55
|
+
/** One published supply event. Mirrors `SupplyEvent.json`. */
|
|
56
|
+
export const SupplyEventSchema = z.looseObject({
|
|
57
|
+
id: z.string(),
|
|
58
|
+
dmd_code: z.string(),
|
|
59
|
+
kind: z.enum(['MSN', 'SSP', 'CONCESSION']),
|
|
60
|
+
status: z.string(),
|
|
61
|
+
detail: z.string().optional(),
|
|
62
|
+
reason_code: z
|
|
63
|
+
.enum(['manufacturing', 'api_supply', 'discontinuation', 'shipping', 'demand', 'unstated'])
|
|
64
|
+
.optional(),
|
|
65
|
+
nations: z.array(z.enum(['ENG', 'SCO', 'WAL', 'NIR'])),
|
|
66
|
+
effective_from: z.string(),
|
|
67
|
+
effective_from_basis: z.enum(['published', 'first_observed']),
|
|
68
|
+
effective_to: z.string().optional(),
|
|
69
|
+
source_url: z.string(),
|
|
70
|
+
source_published_at: z.string(),
|
|
71
|
+
source_published_at_basis: z.enum(['published', 'http_last_modified', 'first_observed']),
|
|
72
|
+
first_seen_at: z.string(),
|
|
73
|
+
});
|
|
74
|
+
/** A detected change in the delta feed. Mirrors `ChangeRecord.json`. */
|
|
75
|
+
export const ChangeRecordSchema = z.looseObject({
|
|
76
|
+
id: z.string(),
|
|
77
|
+
kind: z.enum(['SUPPLY', 'WORKFORCE']),
|
|
78
|
+
dmd_code: z.string().optional(),
|
|
79
|
+
change_type: z.enum(['NEW', 'UPDATED', 'RESOLVED', 'EXPIRED']),
|
|
80
|
+
event_id: z.string().optional(),
|
|
81
|
+
detected_at: z.string(),
|
|
82
|
+
headline: z.string(),
|
|
83
|
+
});
|
|
84
|
+
/** One row of the Workforce Panel's ledger (S15). Mirrors `WorkforceStat.json`. */
|
|
85
|
+
export const WorkforceStatSchema = z.looseObject({
|
|
86
|
+
id: z.string(),
|
|
87
|
+
metric: z.enum(WORKFORCE_METRICS),
|
|
88
|
+
role: z.string().optional(),
|
|
89
|
+
region: z.string().optional(),
|
|
90
|
+
period: z.string(),
|
|
91
|
+
value: z.number(),
|
|
92
|
+
unit: z.string(),
|
|
93
|
+
nhwa_code: z.string().optional(),
|
|
94
|
+
source_url: z.string(),
|
|
95
|
+
source_published_at: z.string(),
|
|
96
|
+
source_published_at_basis: z.enum(['published', 'first_observed']),
|
|
97
|
+
first_seen_at: z.string(),
|
|
98
|
+
});
|
|
99
|
+
/**
|
|
100
|
+
* One entry of the change feed's index. The pipeline publishes no JSON Schema document for the
|
|
101
|
+
* index (it is described in `openapi.json`), so this mirrors `FeedSegment` in
|
|
102
|
+
* `pipeline/src/artefacts/changes.ts` instead.
|
|
103
|
+
*/
|
|
104
|
+
export const ChangeSegmentRefSchema = z.looseObject({
|
|
105
|
+
cursor: z.string(),
|
|
106
|
+
path: z.string(),
|
|
107
|
+
count: z.number(),
|
|
108
|
+
closed: z.boolean(),
|
|
109
|
+
from: z.string(),
|
|
110
|
+
to: z.string(),
|
|
111
|
+
});
|
|
112
|
+
/** `/api/v1/changes/index.json`. Mirrors `ChangeFeedIndex` in `pipeline/src/artefacts/changes.ts`. */
|
|
113
|
+
export const ChangesIndexSchema = z.looseObject({
|
|
114
|
+
api_version: z.string(),
|
|
115
|
+
generated_at: z.string(),
|
|
116
|
+
latest_cursor: z.string().nullable(),
|
|
117
|
+
total_changes: z.number(),
|
|
118
|
+
segments: z.array(ChangeSegmentRefSchema),
|
|
119
|
+
cursor_protocol: z.array(z.string()),
|
|
120
|
+
limitations: z.array(z.string()),
|
|
121
|
+
});
|
|
122
|
+
/**
|
|
123
|
+
* The entity schemas that have a published JSON Schema counterpart, keyed by its title — the
|
|
124
|
+
* registry the contract check walks. Adding a schema here without a published document (or the
|
|
125
|
+
* reverse, for a document this package reads) fails that check.
|
|
126
|
+
*/
|
|
127
|
+
export const PUBLISHED_ENTITY_SCHEMAS = {
|
|
128
|
+
DmdProduct: DmdProductSchema,
|
|
129
|
+
Tier: TierSchema,
|
|
130
|
+
SupplyEvent: SupplyEventSchema,
|
|
131
|
+
ChangeRecord: ChangeRecordSchema,
|
|
132
|
+
WorkforceStat: WorkforceStatSchema,
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* Every data artefact this package fetches, by path under `/api/v1`, with the schema its body
|
|
136
|
+
* must satisfy. Change segments (`changes/<cursor>.json`) are validated as `ChangeRecord[]`.
|
|
137
|
+
*/
|
|
138
|
+
export const ARTEFACT_SCHEMAS = {
|
|
139
|
+
'products.json': z.array(DmdProductSchema),
|
|
140
|
+
'tiers.json': z.array(TierSchema),
|
|
141
|
+
'events.json': z.array(SupplyEventSchema),
|
|
142
|
+
'changes/index.json': ChangesIndexSchema,
|
|
143
|
+
'workforce/stats.json': z.array(WorkforceStatSchema),
|
|
144
|
+
};
|
|
145
|
+
export const ChangeSegmentSchema = z.array(ChangeRecordSchema);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The package version, read once from `package.json`.
|
|
3
|
+
*
|
|
4
|
+
* Previously restated by hand in two places (the MCP server's `version` and the fetch
|
|
5
|
+
* user-agent), and the two had already disagreed (`0.2.0` vs `0.0.0`). `package.json` sits one
|
|
6
|
+
* directory above both `src/` (run via tsx) and `dist/` (the published build), and npm always
|
|
7
|
+
* ships it, so the same relative URL resolves in development and in the installed package.
|
|
8
|
+
*/
|
|
9
|
+
export declare const PACKAGE_VERSION: string;
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
/**
|
|
4
|
+
* The package version, read once from `package.json`.
|
|
5
|
+
*
|
|
6
|
+
* Previously restated by hand in two places (the MCP server's `version` and the fetch
|
|
7
|
+
* user-agent), and the two had already disagreed (`0.2.0` vs `0.0.0`). `package.json` sits one
|
|
8
|
+
* directory above both `src/` (run via tsx) and `dist/` (the published build), and npm always
|
|
9
|
+
* ships it, so the same relative URL resolves in development and in the installed package.
|
|
10
|
+
*/
|
|
11
|
+
export const PACKAGE_VERSION = z
|
|
12
|
+
.object({ version: z.string().min(1) })
|
|
13
|
+
.parse(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))).version;
|
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pharmasync/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"mcpName": "io.github.ayeongo/pharmasync",
|
|
5
|
+
"description": "MCP server for the Pharmasync public API (S8). A read-only client of the static /api/v1 artefacts — no keys, no store, no server infrastructure.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"mcp",
|
|
8
|
+
"model-context-protocol",
|
|
9
|
+
"pharmasync",
|
|
10
|
+
"medicine-supply",
|
|
11
|
+
"dmd",
|
|
12
|
+
"nhs",
|
|
13
|
+
"pharmacy",
|
|
14
|
+
"drug-shortage"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "AyeOnGo (https://github.com/ayeongo)",
|
|
18
|
+
"homepage": "https://github.com/ayeongo/Pharmasync/tree/main/mcp#readme",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/ayeongo/Pharmasync.git",
|
|
22
|
+
"directory": "mcp"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/ayeongo/Pharmasync/issues"
|
|
26
|
+
},
|
|
27
|
+
"type": "module",
|
|
28
|
+
"bin": {
|
|
29
|
+
"pharmasync-mcp": "dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"main": "dist/index.js",
|
|
32
|
+
"types": "dist/index.d.ts",
|
|
33
|
+
"files": [
|
|
34
|
+
"dist"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=24 <26",
|
|
38
|
+
"npm": ">=11.16.0"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"start": "tsx src/index.ts",
|
|
45
|
+
"build": "tsc -p tsconfig.build.json",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"lint": "eslint .",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"check": "npm run lint && npm run typecheck && npm run test",
|
|
50
|
+
"contract": "tsx test/contract-cli.ts",
|
|
51
|
+
"prepack": "npm run build",
|
|
52
|
+
"prepublishOnly": "npm run check"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/node": "^24.3.0",
|
|
56
|
+
"eslint": "^10.11.0",
|
|
57
|
+
"tsx": "^4.23.15",
|
|
58
|
+
"typescript": "^5.9.3",
|
|
59
|
+
"typescript-eslint": "^8.71.0",
|
|
60
|
+
"vitest": "^5.0.2"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
64
|
+
"zod": "^4.6.5"
|
|
65
|
+
},
|
|
66
|
+
"allowScripts": {
|
|
67
|
+
"esbuild@0.28.2": true,
|
|
68
|
+
"fsevents@2.3.3": true
|
|
69
|
+
}
|
|
70
|
+
}
|