pyxis-mcp 0.0.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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Samuel Santana
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.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Samuel Santana
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,7 +1,262 @@
1
- # pyxis-mcp
1
+ <div align="center">
2
2
 
3
- The MCP server of [Pyxis](https://pyxis-analytics.dev), privacy-first product analytics: it lets an
4
- AI agent read your dashboard's numbers with a read-only token you can revoke at any time.
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/samuelcsantana/pyxis-mcp/main/.github/assets/cover-dark.png">
5
+ <img alt="Pyxis — privacy-first product analytics" src="https://raw.githubusercontent.com/samuelcsantana/pyxis-mcp/main/.github/assets/cover-light.png" width="100%">
6
+ </picture>
5
7
 
6
- This version only reserves the name. The first usable release is 0.1.0, published from GitHub
7
- Actions with npm provenance. Source and setup: https://github.com/samuelcsantana/pyxis-mcp
8
+ **The MCP server of Pyxis: lets an AI agent read your dashboard's numbers, with a read-only token
9
+ you can revoke at any time.**
10
+
11
+ [![CI](https://github.com/samuelcsantana/pyxis-mcp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/samuelcsantana/pyxis-mcp/actions/workflows/ci.yml)
12
+ [![Coverage](https://codecov.io/gh/samuelcsantana/pyxis-mcp/graph/badge.svg)](https://codecov.io/gh/samuelcsantana/pyxis-mcp)
13
+ [![CodeQL](https://github.com/samuelcsantana/pyxis-mcp/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/samuelcsantana/pyxis-mcp/actions/workflows/codeql.yml)
14
+ <br>
15
+ [![License: MIT](https://img.shields.io/github/license/samuelcsantana/pyxis-mcp)](LICENSE)
16
+ [![TypeScript strict](https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white)](tsconfig.json)
17
+ [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-FE5196?logo=conventionalcommits&logoColor=white)](https://www.conventionalcommits.org)
18
+ [![npm](https://img.shields.io/npm/v/pyxis-mcp?logo=npm)](https://www.npmjs.com/package/pyxis-mcp)
19
+ [![npm provenance](https://img.shields.io/badge/npm-provenance-2E7D32?logo=npm)](https://www.npmjs.com/package/pyxis-mcp#provenance)
20
+
21
+ **[Pyxis](https://pyxis-analytics.dev)** · **[Live demo](https://demo.pyxis-analytics.dev)** ·
22
+ **[API reference](https://samuelcsantana.github.io/pyxis-api/)**
23
+
24
+ </div>
25
+
26
+ > **Status:** on npm as `pyxis-mcp`, every release published from GitHub Actions with
27
+ > provenance. The fifteen tools read the live Pyxis API. The package also exports them as a
28
+ > library, which a host serves over HTTP with each caller's token.
29
+
30
+ ## Ecosystem
31
+
32
+ | Repository | Role |
33
+ | -------------------------------------------------------- | ------------------------------------------------------------- |
34
+ | [pyxis-api](https://github.com/samuelcsantana/pyxis-api) | Ingestion, dashboard sign-in and queries, erasure, retention |
35
+ | [pyxis-sdk](https://github.com/samuelcsantana/pyxis-sdk) | Browser tracker published to npm as `pyxis-analytics` |
36
+ | [pyxis-web](https://github.com/samuelcsantana/pyxis-web) | The dashboard: overview, funnels, features, requests |
37
+ | **pyxis-mcp** (this one) | MCP server that lets an AI agent read the dashboard's numbers |
38
+
39
+ ## Why it exists
40
+
41
+ More and more questions about a product are asked to an agent instead of a dashboard: "how did
42
+ last week compare", "where does the sign-up funnel lose people", "which route started failing".
43
+ This server gives Claude Code, Claude Desktop, Cursor and other MCP clients the same numbers the
44
+ Pyxis dashboard shows, and nothing more: it uses an access token that only reads, created by an
45
+ admin in the dashboard's Settings and revocable there at any moment. Like the rest of Pyxis, there
46
+ is no personal data to hand over: events carry no cookies, no addresses and no user agents.
47
+
48
+ ## Tools
49
+
50
+ Every tool only reads, and maps to one `GET` route of the Pyxis API. Dates are `YYYY-MM-DD` in the
51
+ project's time zone; `list_projects` gives each project's today, so call it first.
52
+
53
+ | Tool | What it answers |
54
+ | ------------------ | --------------------------------------------------------------------------------------------------------- |
55
+ | `list_projects` | The projects the token can read: id, name, time zone, today there, conversion event, first and last event |
56
+ | `overview` | Visits, identified users, conversions and failed writes against the previous period; top pages and events |
57
+ | `devices` | Visits and conversions by device type, browser, system, country and region |
58
+ | `acquisition` | Visits per day and channel, and the sources and campaigns behind them |
59
+ | `engagement` | Entry and exit pages, single-page visits, visit length |
60
+ | `time_of_day` | Visits by weekday and local hour |
61
+ | `features` | The most used named events or screens, day by day |
62
+ | `event_properties` | How the property values of one named event break down |
63
+ | `requests` | Calls to the site's backend per route: failures, statuses, durations, latest errors |
64
+ | `funnel` | How many visits or people reached each step, and the time between steps |
65
+ | `funnel_segments` | The same funnel per device type or channel |
66
+ | `funnel_subjects` | The visits or people who reached, or dropped at, one step |
67
+ | `visits` | The visits of a range, filtered by pages, events, channel, device, country, campaign, failed requests |
68
+ | `visit_timeline` | Every event of one visit, or every visit of one identified person |
69
+ | `project_settings` | Time zone, conversion event, allowed origins, retention, live public keys |
70
+
71
+ A result is JSON text, `{ "untrusted_visitor_text": "…", "report": … }`: the report is the API's
72
+ answer, and the first field reminds the agent that names, paths and values inside it were written by
73
+ the site's visitors. Problems come back as tool errors the agent can act on (a revoked token, an
74
+ unknown project, the rate limit and when to retry). When the API refuses a request (any `4xx`), the
75
+ error carries the API's own code and reason, such as a suspended account; only a `5xx` asks to try
76
+ again later.
77
+
78
+ ## Configuration
79
+
80
+ | Variable | Required | Meaning |
81
+ | --------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
82
+ | `PYXIS_TOKEN` | yes | A read-only access token (`pyxis_at_` and 32 letters and digits), created in the dashboard under Settings |
83
+ | `PYXIS_API_URL` | no | The Pyxis API to read, as an origin. Defaults to `https://api.pyxis-analytics.dev`; plain http only for localhost |
84
+
85
+ A missing or malformed value stops the server with a sentence on standard error that names the
86
+ variable and never repeats its value.
87
+
88
+ ## Use it
89
+
90
+ Create a token in the dashboard (Settings, Access tokens), then add the server to your client. It
91
+ runs with `npx`, so there is nothing to install beforehand (Node 22 or later).
92
+
93
+ Claude Code:
94
+
95
+ ```bash
96
+ claude mcp add pyxis -e PYXIS_TOKEN=pyxis_at_… -- npx -y pyxis-mcp
97
+ ```
98
+
99
+ Claude Desktop (`claude_desktop_config.json`) and Cursor (`.cursor/mcp.json`):
100
+
101
+ ```json
102
+ {
103
+ "mcpServers": {
104
+ "pyxis": {
105
+ "command": "npx",
106
+ "args": ["-y", "pyxis-mcp"],
107
+ "env": { "PYXIS_TOKEN": "pyxis_at_…" }
108
+ }
109
+ }
110
+ }
111
+ ```
112
+
113
+ A self-hosted Pyxis adds `PYXIS_API_URL`. To run it from source instead, `npm ci && npm run build`
114
+ in a clone and use `node /path/to/pyxis-mcp/dist/cli.js` as the command. Keep the token out of
115
+ shared files and chats; revoke it in the dashboard the moment it leaks.
116
+
117
+ ## Use it as a library
118
+
119
+ Clients that only accept remote servers need the tools at a URL. The package exports them so a
120
+ host can serve them from its own endpoint, over Streamable HTTP, with the token of whoever is
121
+ calling. It is an ES module that a CommonJS host can also `require()` (Node 22.12 or later).
122
+
123
+ ```ts
124
+ import { handleHttpRequest, PyxisApi } from 'pyxis-mcp';
125
+
126
+ export function answerMcp(request: Request, callerToken: string): Promise<Response> {
127
+ const api = new PyxisApi({
128
+ token: callerToken,
129
+ apiUrl: 'https://api.pyxis-analytics.dev',
130
+ problemTexts: {
131
+ refusedToken: 'Pyxis refused this connection: connect Pyxis again in your MCP client.',
132
+ unreachableAdvice: 'Try again in a moment.',
133
+ },
134
+ });
135
+ return handleHttpRequest(request, api);
136
+ }
137
+ ```
138
+
139
+ | Export | What it does |
140
+ | ------------------------------------------- | --------------------------------------------------------------------- |
141
+ | `handleHttpRequest(request, api, options?)` | Answers one MCP exchange: a web `Request` in, a web `Response` out |
142
+ | `createServer(api, options?)` | The MCP server with the fifteen tools, for a transport of your choice |
143
+ | `new PyxisApi(options)` | The client the tools read through, one per caller |
144
+
145
+ `PyxisApi` options: `token` and `apiUrl` (required); `userAgent` (default `pyxis-mcp/<version>`);
146
+ `fetch` (default the global one; a host can pass one that answers inside its own process);
147
+ `timeoutMs` (default 15 seconds); `problemTexts`, the two sentences that depend on where the server
148
+ runs: `refusedToken` and `unreachableAdvice` (default: the local server's, which name
149
+ `PYXIS_TOKEN` and `PYXIS_API_URL`). `createServer` takes `version` (default the package's) and
150
+ `now` (the clock); `handleHttpRequest` takes those and `allowedOrigins`.
151
+
152
+ What the handler does: each request gets a server and a transport of its own, closed once it is
153
+ answered, so there is no session id and no event stream, only JSON answers. It answers `405` with
154
+ `Allow: POST` to any other method, `403` to a request with an `Origin` header unless that origin is
155
+ in `allowedOrigins` (none by default: MCP clients do not call from web pages), and `413` to a body
156
+ over 64 KiB. What it leaves to the host: authenticating the caller (and the `401` with
157
+ `WWW-Authenticate` that starts a client's sign-in), rate limits and the host name
158
+ ([ADR 0003](docs/adr/0003-stateless-http-handler-for-hosts.md)).
159
+
160
+ ## Architecture
161
+
162
+ ```mermaid
163
+ flowchart LR
164
+ Agent["MCP client<br>(Claude Code, Cursor…)"] -- "stdio, JSON-RPC" --> Server["pyxis-mcp<br>(on the user's machine)"]
165
+ Server -- "HTTPS GET<br>Authorization: Bearer pyxis_at_…" --> API["Pyxis API"]
166
+ Remote["Remote MCP client"] -- "Streamable HTTP POST<br>with the caller's token" --> Host["A host's endpoint<br>handleHttpRequest"]
167
+ Host -- "GET with the same token" --> API
168
+ API --> DB[("Events")]
169
+ ```
170
+
171
+ The local server opens no port and keeps no state. The API decides what the token may read,
172
+ exactly as it does for a dashboard session ([ADR 0002](docs/adr/0002-local-read-only-server.md)).
173
+ A host runs the same tools for remote clients: it authenticates the caller, and the tools read with
174
+ that caller's token, so the token never leaves the server that issued it
175
+ ([ADR 0003](docs/adr/0003-stateless-http-handler-for-hosts.md)).
176
+
177
+ ## Tech stack
178
+
179
+ TypeScript (strict) on Node 22 or later, ES modules. Vitest with V8 coverage, ESLint
180
+ (`typescript-eslint` strict, type-checked) and Prettier, Husky and commitlint, GitHub Actions with
181
+ CodeQL, Dependabot and release-please.
182
+
183
+ ## Getting started
184
+
185
+ ```bash
186
+ npm ci
187
+ npm run lint && npm run typecheck && npm run test:cov && npm run build
188
+ ```
189
+
190
+ ## Testing
191
+
192
+ - `npm run test:cov` runs the unit tests in Node and fails below **100%** of statements, branches,
193
+ functions and lines. The server is tested with a real MCP client over an in-memory transport and
194
+ a fake `fetch`; the HTTP handler with web `Request` objects, from `initialize` to a tool call,
195
+ and every refusal.
196
+ - One file is left out of the unit coverage: `src/cli.ts`, which only wires the modules to the
197
+ standard input and output. `npm run test:e2e` covers it: after `npm run build`, a real MCP client
198
+ starts `dist/cli.js`, lists the tools and calls them against a local fake of the API, checks that
199
+ standard output carries nothing but the protocol, and that a bad configuration stops the process
200
+ with the reason on standard error.
201
+ - `npm run test:e2e` also starts `e2e/http-host.ts`, a small HTTP host that imports `pyxis-mcp` by
202
+ its name, and drives it with the SDK's Streamable HTTP client: tools against the fake API with
203
+ the caller's token, no session, the host's own words for a refused token, `405` and `403`. The
204
+ package is also loaded by name with `import` and with `require`.
205
+ - `src/contract.test.ts` checks the tools against `contract/openapi.json`, the API's own contract
206
+ (`npm run contract:sync` copies it from pyxis-api): every route a token may read has a tool, and
207
+ every tool sends only declared parameters, with values inside their enums.
208
+ - `npm run test:tooling` tests the lint rule and the scripts.
209
+ - CI runs the unit tests on Node 24 and again on Node 22, the oldest version the package supports.
210
+
211
+ ## Project structure
212
+
213
+ ```
214
+ src/
215
+ ├── config.ts reads PYXIS_TOKEN and PYXIS_API_URL
216
+ ├── pyxis-api.ts one GET to the API, failures as sentences
217
+ ├── tools.ts the fifteen tools: input schemas, routes, requests
218
+ ├── server.ts registers the tools on the MCP server
219
+ ├── http.ts one stateless Streamable HTTP exchange
220
+ ├── index.ts the library's public exports
221
+ ├── version.ts the package version, for the server and the user agent
222
+ └── cli.ts the executable: stdio transport
223
+ e2e/ the built package driven by real MCP clients, over stdio and over HTTP
224
+ contract/ the API's OpenAPI contract, copied from pyxis-api
225
+ eslint-rules/ the local rule that forbids comments
226
+ scripts/ the comment check and the contract sync
227
+ docs/adr/ architecture decision records
228
+ ```
229
+
230
+ ## Privacy and security
231
+
232
+ - Read-only: the API accepts the token only on its read routes; it cannot write, sign in, manage
233
+ sessions or create tokens.
234
+ - The token only travels in the `Authorization` header to the configured API, over https (plain
235
+ http only to localhost), and never reaches a log, an error message or a tool result.
236
+ - Text written by a site's visitors is returned as data, and each tool description says it is not
237
+ an instruction.
238
+ - Over HTTP, the handler keeps no session, refuses requests sent by web pages and bodies over 64
239
+ KiB, and reads with the token of the caller its host authenticated.
240
+ - Vulnerabilities: see [SECURITY.md](SECURITY.md).
241
+
242
+ ## Architecture decisions
243
+
244
+ | ADR | Decision |
245
+ | --------------------------------------------------------- | ----------------------------------------------------------------- |
246
+ | [0001](docs/adr/0001-record-architecture-decisions.md) | Record architecture decisions |
247
+ | [0002](docs/adr/0002-local-read-only-server.md) | A local, read-only server that uses the admin's access token |
248
+ | [0003](docs/adr/0003-stateless-http-handler-for-hosts.md) | A stateless HTTP handler that a host runs with the caller's token |
249
+
250
+ ## Roadmap
251
+
252
+ - [x] Repository skeleton, quality gates, configuration reader
253
+ - [x] Tools over the API's read routes, tested against its OpenAPI contract
254
+ - [x] First release on npm, with provenance
255
+ - [x] The tools as a library, served over stateless Streamable HTTP by a host
256
+ - [ ] A remote endpoint in the Pyxis API, signed in with OAuth, for clients that only take URLs
257
+ - [ ] Setup guides for Claude Code, Claude Desktop and Cursor
258
+
259
+ ## Contributing and license
260
+
261
+ See [CONTRIBUTING.md](CONTRIBUTING.md) and the [Code of Conduct](CODE_OF_CONDUCT.md). MIT licensed:
262
+ see [LICENSE](LICENSE).
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { ConfigError, readConfig } from './config.js';
4
+ import { PyxisApi } from './pyxis-api.js';
5
+ import { createServer } from './server.js';
6
+ const EXIT_FAILURE = 1;
7
+ async function start() {
8
+ const server = createServer(new PyxisApi(readConfig(process.env)));
9
+ await server.connect(new StdioServerTransport());
10
+ }
11
+ start().catch((error) => {
12
+ const reason = error instanceof ConfigError
13
+ ? error.message
14
+ : `could not start: ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`;
15
+ process.stderr.write(`pyxis-mcp: ${reason}\n`);
16
+ process.exitCode = EXIT_FAILURE;
17
+ });
18
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACtD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,MAAM,YAAY,GAAG,CAAC,CAAC;AAEvB,KAAK,UAAU,KAAK;IAClB,MAAM,MAAM,GAAG,YAAY,CAAC,IAAI,QAAQ,CAAC,UAAU,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IACnE,MAAM,MAAM,CAAC,OAAO,CAAC,IAAI,oBAAoB,EAAE,CAAC,CAAC;AACnD,CAAC;AAED,KAAK,EAAE,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;IAC/B,MAAM,MAAM,GACV,KAAK,YAAY,WAAW;QAC1B,CAAC,CAAC,KAAK,CAAC,OAAO;QACf,CAAC,CAAC,oBAAoB,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,IAAI,KAAK,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;IACvG,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,cAAc,MAAM,IAAI,CAAC,CAAC;IAC/C,OAAO,CAAC,QAAQ,GAAG,YAAY,CAAC;AAClC,CAAC,CAAC,CAAC"}
@@ -0,0 +1,13 @@
1
+ export declare const DEFAULT_API_URL = "https://api.pyxis-analytics.dev";
2
+ export declare const ACCESS_TOKEN_PATTERN: RegExp;
3
+ export interface PyxisConfig {
4
+ readonly token: string;
5
+ readonly apiUrl: string;
6
+ }
7
+ export type ConfigProblem = 'missing-token' | 'malformed-token' | 'invalid-api-url' | 'insecure-api-url';
8
+ export declare class ConfigError extends Error {
9
+ readonly problem: ConfigProblem;
10
+ constructor(problem: ConfigProblem);
11
+ }
12
+ export type Environment = Readonly<Record<string, string | undefined>>;
13
+ export declare function readConfig(environment: Environment): PyxisConfig;
package/dist/config.js ADDED
@@ -0,0 +1,64 @@
1
+ export const DEFAULT_API_URL = 'https://api.pyxis-analytics.dev';
2
+ export const ACCESS_TOKEN_PATTERN = /^pyxis_at_[A-Za-z0-9]{32}$/;
3
+ const SECURE_PROTOCOL = 'https:';
4
+ const LOCAL_PROTOCOL = 'http:';
5
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
6
+ const ROOT_PATH = '/';
7
+ const PROBLEM_MESSAGES = {
8
+ 'missing-token': 'Set PYXIS_TOKEN to a read-only access token created in the Pyxis dashboard, under Settings, Access tokens.',
9
+ 'malformed-token': 'PYXIS_TOKEN is not a Pyxis access token: it starts with pyxis_at_ followed by 32 letters and digits.',
10
+ 'invalid-api-url': 'PYXIS_API_URL must be the address of a Pyxis API with no path, such as https://api.pyxis-analytics.dev.',
11
+ 'insecure-api-url': 'PYXIS_API_URL must use https; plain http is accepted only for localhost.',
12
+ };
13
+ export class ConfigError extends Error {
14
+ problem;
15
+ constructor(problem) {
16
+ super(PROBLEM_MESSAGES[problem]);
17
+ this.problem = problem;
18
+ this.name = 'ConfigError';
19
+ }
20
+ }
21
+ export function readConfig(environment) {
22
+ return {
23
+ token: readToken(environment.PYXIS_TOKEN),
24
+ apiUrl: readApiUrl(environment.PYXIS_API_URL),
25
+ };
26
+ }
27
+ function readToken(raw) {
28
+ const token = raw?.trim() ?? '';
29
+ if (token === '') {
30
+ throw new ConfigError('missing-token');
31
+ }
32
+ if (!ACCESS_TOKEN_PATTERN.test(token)) {
33
+ throw new ConfigError('malformed-token');
34
+ }
35
+ return token;
36
+ }
37
+ function isBareOrigin(url) {
38
+ return (url.username === '' &&
39
+ url.password === '' &&
40
+ url.pathname === ROOT_PATH &&
41
+ url.search === '' &&
42
+ url.hash === '');
43
+ }
44
+ function readApiUrl(raw) {
45
+ const value = raw?.trim() ?? '';
46
+ if (value === '') {
47
+ return DEFAULT_API_URL;
48
+ }
49
+ const url = URL.parse(value);
50
+ if (url === null || !isBareOrigin(url)) {
51
+ throw new ConfigError('invalid-api-url');
52
+ }
53
+ if (url.protocol === SECURE_PROTOCOL) {
54
+ return url.origin;
55
+ }
56
+ if (url.protocol !== LOCAL_PROTOCOL) {
57
+ throw new ConfigError('invalid-api-url');
58
+ }
59
+ if (!LOCAL_HOSTS.has(url.hostname)) {
60
+ throw new ConfigError('insecure-api-url');
61
+ }
62
+ return url.origin;
63
+ }
64
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG,iCAAiC,CAAC;AACjE,MAAM,CAAC,MAAM,oBAAoB,GAAG,4BAA4B,CAAC;AAEjE,MAAM,eAAe,GAAG,QAAQ,CAAC;AACjC,MAAM,cAAc,GAAG,OAAO,CAAC;AAC/B,MAAM,WAAW,GAAwB,IAAI,GAAG,CAAC,CAAC,WAAW,EAAE,WAAW,EAAE,OAAO,CAAC,CAAC,CAAC;AACtF,MAAM,SAAS,GAAG,GAAG,CAAC;AAUtB,MAAM,gBAAgB,GAA4C;IAChE,eAAe,EACb,4GAA4G;IAC9G,iBAAiB,EACf,sGAAsG;IACxG,iBAAiB,EACf,yGAAyG;IAC3G,kBAAkB,EAAE,0EAA0E;CAC/F,CAAC;AAEF,MAAM,OAAO,WAAY,SAAQ,KAAK;IACf;IAArB,YAAqB,OAAsB;QACzC,KAAK,CAAC,gBAAgB,CAAC,OAAO,CAAC,CAAC,CAAC;QADd,YAAO,GAAP,OAAO,CAAe;QAEzC,IAAI,CAAC,IAAI,GAAG,aAAa,CAAC;IAC5B,CAAC;CACF;AAID,MAAM,UAAU,UAAU,CAAC,WAAwB;IACjD,OAAO;QACL,KAAK,EAAE,SAAS,CAAC,WAAW,CAAC,WAAW,CAAC;QACzC,MAAM,EAAE,UAAU,CAAC,WAAW,CAAC,aAAa,CAAC;KAC9C,CAAC;AACJ,CAAC;AAED,SAAS,SAAS,CAAC,GAAuB;IACxC,MAAM,KAAK,GAAG,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;IAChC,IAAI,KAAK,KAAK,EAAE,EAAE,CAAC;QACjB,MAAM,IAAI,WAAW,CAAC,eAAe,CAAC,CAAC;IACzC,CAAC;IACD,IAAI,CAAC,oBAAoB,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QACtC,MAAM,IAAI,WAAW,CAAC,iBAAiB,CAAC,CAAC;IAC3C,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,YAAY,CAAC,GAAQ;IAC5B,OAAO,CACL,GAAG,CAAC,QAAQ,KAAK,EAAE;QACnB,GAAG,CAAC,QAAQ,KAAK,EAAE;QACnB,GAAG,CAAC,QAAQ,KAAK,SAAS;QAC1B,GAAG,CAAC,MAAM,KAAK,EAAE;QACjB,GAAG,CAAC,IAAI,KAAK,EAAE,CAChB,CAAC;AACJ,CAAC;AAED,SAAS,UAAU,CAAC,GAAuB;IACzC,MAAM,KAAK,GAAG,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;IAChC,IAAI,KAAK,KAAK,EAAE,EAAE,CAAC;QACjB,OAAO,eAAe,CAAC;IACzB,CAAC;IACD,MAAM,GAAG,GAAG,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IAC7B,IAAI,GAAG,KAAK,IAAI,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,EAAE,CAAC;QACvC,MAAM,IAAI,WAAW,CAAC,iBAAiB,CAAC,CAAC;IAC3C,CAAC;IACD,IAAI,GAAG,CAAC,QAAQ,KAAK,eAAe,EAAE,CAAC;QACrC,OAAO,GAAG,CAAC,MAAM,CAAC;IACpB,CAAC;IACD,IAAI,GAAG,CAAC,QAAQ,KAAK,cAAc,EAAE,CAAC;QACpC,MAAM,IAAI,WAAW,CAAC,iBAAiB,CAAC,CAAC;IAC3C,CAAC;IACD,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC;QACnC,MAAM,IAAI,WAAW,CAAC,kBAAkB,CAAC,CAAC;IAC5C,CAAC;IACD,OAAO,GAAG,CAAC,MAAM,CAAC;AACpB,CAAC"}
package/dist/http.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { PyxisApi } from './pyxis-api.js';
2
+ import { type ServerOptions } from './server.js';
3
+ export declare const MAX_REQUEST_BYTES: number;
4
+ export declare const ALLOWED_METHOD = "POST";
5
+ export declare const HTTP_REFUSALS: {
6
+ readonly method: "Method not allowed: this server keeps no session and answers POST only.";
7
+ readonly origin: "Forbidden: this server does not answer requests sent by a web page.";
8
+ };
9
+ export interface HttpOptions extends ServerOptions {
10
+ readonly allowedOrigins?: readonly string[];
11
+ }
12
+ export declare function handleHttpRequest(request: Request, api: PyxisApi, options?: HttpOptions): Promise<Response>;
package/dist/http.js ADDED
@@ -0,0 +1,44 @@
1
+ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
2
+ import { createServer } from './server.js';
3
+ export const MAX_REQUEST_BYTES = 64 * 1024;
4
+ export const ALLOWED_METHOD = 'POST';
5
+ export const HTTP_REFUSALS = {
6
+ method: 'Method not allowed: this server keeps no session and answers POST only.',
7
+ origin: 'Forbidden: this server does not answer requests sent by a web page.',
8
+ };
9
+ const STATUS_FORBIDDEN = 403;
10
+ const STATUS_METHOD_NOT_ALLOWED = 405;
11
+ const JSON_RPC_SERVER_ERROR = -32000;
12
+ const JSON_MEDIA_TYPE = 'application/json';
13
+ function refusal(status, message, headers = {}) {
14
+ return new Response(JSON.stringify({
15
+ jsonrpc: '2.0',
16
+ error: { code: JSON_RPC_SERVER_ERROR, message },
17
+ id: null,
18
+ }), { status, headers: { 'content-type': JSON_MEDIA_TYPE, ...headers } });
19
+ }
20
+ function comesFromForeignPage(request, allowedOrigins) {
21
+ const origin = request.headers.get('origin');
22
+ return origin !== null && !allowedOrigins.includes(origin);
23
+ }
24
+ export async function handleHttpRequest(request, api, options = {}) {
25
+ if (request.method !== ALLOWED_METHOD) {
26
+ return refusal(STATUS_METHOD_NOT_ALLOWED, HTTP_REFUSALS.method, { allow: ALLOWED_METHOD });
27
+ }
28
+ if (comesFromForeignPage(request, options.allowedOrigins ?? [])) {
29
+ return refusal(STATUS_FORBIDDEN, HTTP_REFUSALS.origin);
30
+ }
31
+ const server = createServer(api, options);
32
+ const transport = new WebStandardStreamableHTTPServerTransport({
33
+ enableJsonResponse: true,
34
+ maxRequestBodySize: MAX_REQUEST_BYTES,
35
+ });
36
+ await server.connect(transport);
37
+ try {
38
+ return await transport.handleRequest(request);
39
+ }
40
+ finally {
41
+ await server.close();
42
+ }
43
+ }
44
+ //# sourceMappingURL=http.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.js","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,wCAAwC,EAAE,MAAM,+DAA+D,CAAC;AAEzH,OAAO,EAAE,YAAY,EAAsB,MAAM,aAAa,CAAC;AAE/D,MAAM,CAAC,MAAM,iBAAiB,GAAG,EAAE,GAAG,IAAI,CAAC;AAC3C,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC;AAErC,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,MAAM,EAAE,yEAAyE;IACjF,MAAM,EAAE,qEAAqE;CACrE,CAAC;AAEX,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAC7B,MAAM,yBAAyB,GAAG,GAAG,CAAC;AACtC,MAAM,qBAAqB,GAAG,CAAC,KAAK,CAAC;AACrC,MAAM,eAAe,GAAG,kBAAkB,CAAC;AAM3C,SAAS,OAAO,CAAC,MAAc,EAAE,OAAe,EAAE,UAAkC,EAAE;IACpF,OAAO,IAAI,QAAQ,CACjB,IAAI,CAAC,SAAS,CAAC;QACb,OAAO,EAAE,KAAK;QACd,KAAK,EAAE,EAAE,IAAI,EAAE,qBAAqB,EAAE,OAAO,EAAE;QAC/C,EAAE,EAAE,IAAI;KACT,CAAC,EACF,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,cAAc,EAAE,eAAe,EAAE,GAAG,OAAO,EAAE,EAAE,CACrE,CAAC;AACJ,CAAC;AAED,SAAS,oBAAoB,CAAC,OAAgB,EAAE,cAAiC;IAC/E,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IAC7C,OAAO,MAAM,KAAK,IAAI,IAAI,CAAC,cAAc,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;AAC7D,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,OAAgB,EAChB,GAAa,EACb,UAAuB,EAAE;IAEzB,IAAI,OAAO,CAAC,MAAM,KAAK,cAAc,EAAE,CAAC;QACtC,OAAO,OAAO,CAAC,yBAAyB,EAAE,aAAa,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAC;IAC7F,CAAC;IACD,IAAI,oBAAoB,CAAC,OAAO,EAAE,OAAO,CAAC,cAAc,IAAI,EAAE,CAAC,EAAE,CAAC;QAChE,OAAO,OAAO,CAAC,gBAAgB,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACzD,CAAC;IACD,MAAM,MAAM,GAAG,YAAY,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;IAC1C,MAAM,SAAS,GAAG,IAAI,wCAAwC,CAAC;QAC7D,kBAAkB,EAAE,IAAI;QACxB,kBAAkB,EAAE,iBAAiB;KACtC,CAAC,CAAC;IACH,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IAChC,IAAI,CAAC;QACH,OAAO,MAAM,SAAS,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;IAChD,CAAC;YAAS,CAAC;QACT,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;IACvB,CAAC;AACH,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { handleHttpRequest, type HttpOptions } from './http.js';
2
+ export { PyxisApi, type ApiOutcome, type Fetch, type ProblemTexts, type PyxisApiOptions, type Query, type QueryValue, } from './pyxis-api.js';
3
+ export { createServer, type ServerOptions } from './server.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { handleHttpRequest } from './http.js';
2
+ export { PyxisApi, } from './pyxis-api.js';
3
+ export { createServer } from './server.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAoB,MAAM,WAAW,CAAC;AAChE,OAAO,EACL,QAAQ,GAOT,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,YAAY,EAAsB,MAAM,aAAa,CAAC"}
@@ -0,0 +1,40 @@
1
+ import type { PyxisConfig } from './config.js';
2
+ export declare const REQUEST_TIMEOUT_MS = 15000;
3
+ export declare const REQUESTS_PER_MINUTE = 60;
4
+ export type QueryValue = string | number | boolean | readonly string[] | undefined;
5
+ export type Query = Readonly<Record<string, QueryValue>>;
6
+ export type ApiOutcome = {
7
+ readonly ok: true;
8
+ readonly body: unknown;
9
+ } | {
10
+ readonly ok: false;
11
+ readonly problem: string;
12
+ };
13
+ export type Fetch = (url: string, init: RequestInit) => Promise<Response>;
14
+ export interface ProblemTexts {
15
+ readonly refusedToken: string;
16
+ readonly unreachableAdvice: string;
17
+ }
18
+ export declare const LOCAL_PROBLEM_TEXTS: ProblemTexts;
19
+ export interface PyxisApiOptions extends PyxisConfig {
20
+ readonly userAgent?: string;
21
+ readonly fetch?: Fetch;
22
+ readonly timeoutMs?: number;
23
+ readonly problemTexts?: ProblemTexts;
24
+ }
25
+ export declare const PROBLEMS: {
26
+ readonly notFound: "Not found: no project with this id among the projects this token can read. Call list_projects for the ids.";
27
+ readonly rejected: "The Pyxis API refused the request.";
28
+ readonly notJson: "The Pyxis API answered with something that is not JSON.";
29
+ };
30
+ export declare function requestUrl(apiUrl: string, path: string, query: Query): string;
31
+ export declare class PyxisApi {
32
+ private readonly token;
33
+ private readonly apiUrl;
34
+ private readonly userAgent;
35
+ private readonly fetch;
36
+ private readonly timeoutMs;
37
+ private readonly problemTexts;
38
+ constructor(options: PyxisApiOptions);
39
+ get(path: string, query?: Query): Promise<ApiOutcome>;
40
+ }