@davepi/mcp 1.0.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/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # @davepi/mcp
2
+
3
+ One-line MCP wiring for [dAvePi](https://docs.davepi.dev). Connects
4
+ Claude Desktop / Cursor / Claude Code to a dAvePi instance — either
5
+ a remote HTTP `/mcp` endpoint or a local stdio session.
6
+
7
+ ## Install / wire it up
8
+
9
+ You don't install — `npx -y` runs the latest published version on
10
+ demand.
11
+
12
+ ### Claude Code (`.mcp.json` in the project root)
13
+
14
+ ```json
15
+ {
16
+ "mcpServers": {
17
+ "davepi": {
18
+ "command": "npx",
19
+ "args": ["-y", "@davepi/mcp"],
20
+ "env": {
21
+ "DAVEPI_URL": "http://localhost:5050",
22
+ "DAVEPI_TOKEN": "<long-lived-jwt>"
23
+ }
24
+ }
25
+ }
26
+ }
27
+ ```
28
+
29
+ ### Claude Desktop (`claude_desktop_config.json`)
30
+
31
+ macOS path: `~/Library/Application Support/Claude/claude_desktop_config.json`.
32
+
33
+ ```json
34
+ {
35
+ "mcpServers": {
36
+ "davepi": {
37
+ "command": "npx",
38
+ "args": ["-y", "@davepi/mcp"],
39
+ "env": {
40
+ "DAVEPI_URL": "https://api.example.com",
41
+ "DAVEPI_TOKEN": "<long-lived-jwt>"
42
+ }
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ ### Cursor (`.cursor/mcp.json` or settings)
49
+
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "davepi": {
54
+ "command": "npx",
55
+ "args": ["-y", "@davepi/mcp"],
56
+ "env": {
57
+ "DAVEPI_URL": "https://api.example.com",
58
+ "DAVEPI_TOKEN": "<long-lived-jwt>"
59
+ }
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ ## Modes
66
+
67
+ The wrapper picks one mode based on environment:
68
+
69
+ | Env vars | Mode | Use when |
70
+ |----------|------|----------|
71
+ | `DAVEPI_URL` + `DAVEPI_TOKEN` | HTTP-proxy | dAvePi is hosted (production deployment, demo instance, etc.) and the agent runs on a developer's laptop. |
72
+ | `DAVEPI_SCHEMAS` (or no env vars in a project with a `schema/versions/` directory) | Local-stdio | dAvePi is installed in the same project as the agent's working tree (`npm install davepi`). The wrapper spawns `davepi mcp` and pipes its stdio. |
73
+
74
+ Both modes are pure JSON-RPC pumps — neither holds any MCP-protocol
75
+ state, so a future MCP version doesn't require touching this
76
+ package.
77
+
78
+ ## Issuing a long-lived JWT for `DAVEPI_TOKEN`
79
+
80
+ Run this on the dAvePi server (or anywhere with `TOKEN_KEY`):
81
+
82
+ ```bash
83
+ node -e '
84
+ const jwt = require("jsonwebtoken");
85
+ console.log(jwt.sign(
86
+ { user_id: "<your-user-id>", roles: ["user"] },
87
+ process.env.TOKEN_KEY,
88
+ { expiresIn: "30d" }
89
+ ));
90
+ '
91
+ ```
92
+
93
+ Treat the result like any other API credential.
94
+
95
+ ## Documentation
96
+
97
+ - [Surfaces → MCP server](https://docs.davepi.dev/surfaces/mcp/) — the full MCP tool surface dAvePi exposes.
98
+ - [Concepts → Why agents come first](https://docs.davepi.dev/concepts/agent-first/) — why MCP is first-class.
99
+
100
+ ## License
101
+
102
+ ISC. Source: <https://github.com/projik/davepi/tree/main/packages/mcp>.
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @davepi/mcp — one-line MCP wiring for a dAvePi instance.
4
+ *
5
+ * Two modes, picked by environment:
6
+ *
7
+ * HTTP-proxy (DAVEPI_URL set)
8
+ * The wrapper runs as the agent's MCP server (stdio JSON-RPC)
9
+ * and forwards every message to the remote dAvePi's /mcp HTTP
10
+ * endpoint with `Authorization: Bearer ${DAVEPI_TOKEN}`. Use
11
+ * when the dAvePi instance is hosted (demo.davepi.dev,
12
+ * production deployment, etc.) and the agent runs on a
13
+ * developer's laptop.
14
+ *
15
+ * Local-stdio (DAVEPI_SCHEMAS set, DAVEPI_URL unset)
16
+ * The wrapper spawns `davepi mcp` from the project's locally
17
+ * installed `davepi` package and pipes its stdio. Use when
18
+ * dAvePi is installed in the same project as the agent's
19
+ * working tree. This mode requires the project to have
20
+ * `davepi` as a dependency; we surface a helpful error if the
21
+ * binary can't be found.
22
+ *
23
+ * Both modes are pure stdio JSON-RPC pumps — neither holds any
24
+ * MCP-protocol state of its own, so a future MCP version doesn't
25
+ * require touching this package.
26
+ */
27
+
28
+ 'use strict';
29
+
30
+ const path = require('path');
31
+
32
+ const HELP = `Usage: davepi-mcp
33
+
34
+ Pick one mode by environment:
35
+
36
+ HTTP-proxy mode:
37
+ DAVEPI_URL URL of the dAvePi server (e.g. https://api.example.com)
38
+ DAVEPI_TOKEN Long-lived JWT issued by /login on that server
39
+
40
+ Local-stdio mode:
41
+ DAVEPI_SCHEMAS Path to schema/versions/ (defaults to ./schema/versions)
42
+
43
+ Examples:
44
+
45
+ # Wire an agent to a hosted dAvePi:
46
+ DAVEPI_URL=https://api.example.com DAVEPI_TOKEN=eyJ... npx -y @davepi/mcp
47
+
48
+ # Wire an agent to local schemas (project must have \`davepi\` installed):
49
+ DAVEPI_SCHEMAS=./schema/versions npx -y @davepi/mcp
50
+
51
+ Documentation: https://docs.davepi.dev/surfaces/mcp/
52
+ `;
53
+
54
+ function err(msg) {
55
+ process.stderr.write(`davepi-mcp: ${msg}\n`);
56
+ }
57
+
58
+ function help() {
59
+ process.stderr.write(HELP);
60
+ }
61
+
62
+ async function main() {
63
+ const args = process.argv.slice(2);
64
+ if (args.includes('--help') || args.includes('-h')) {
65
+ help();
66
+ process.exit(0);
67
+ }
68
+
69
+ const url = process.env.DAVEPI_URL;
70
+ const schemas = process.env.DAVEPI_SCHEMAS;
71
+
72
+ if (url) {
73
+ if (!process.env.DAVEPI_TOKEN) {
74
+ err('DAVEPI_URL is set but DAVEPI_TOKEN is missing. Set both for HTTP-proxy mode.');
75
+ help();
76
+ process.exit(1);
77
+ }
78
+ const { runHttpProxy } = require(path.join('..', 'lib', 'http-proxy.js'));
79
+ return runHttpProxy({
80
+ url,
81
+ token: process.env.DAVEPI_TOKEN,
82
+ });
83
+ }
84
+
85
+ if (schemas || args.length === 0) {
86
+ // Local-stdio mode. Schemas path defaults to ./schema/versions
87
+ // (the davepi convention) so a user with a project structured
88
+ // the standard way can omit DAVEPI_SCHEMAS entirely.
89
+ const { runLocalStdio } = require(path.join('..', 'lib', 'local-stdio.js'));
90
+ return runLocalStdio({
91
+ schemas: schemas || path.join(process.cwd(), 'schema', 'versions'),
92
+ });
93
+ }
94
+
95
+ help();
96
+ process.exit(1);
97
+ }
98
+
99
+ main().catch((e) => {
100
+ err(e && e.stack ? e.stack : String(e));
101
+ process.exit(1);
102
+ });
@@ -0,0 +1,210 @@
1
+ /**
2
+ * HTTP-proxy mode: bridge stdio JSON-RPC ↔ remote /mcp HTTP endpoint.
3
+ *
4
+ * Why a manual stdio loop instead of the MCP SDK's bridge classes:
5
+ * the MCP protocol is JSON-RPC 2.0 with specific method names. The
6
+ * wrapper doesn't need to understand any of the methods — it just
7
+ * forwards bytes — so importing the SDK would add ~MB of code to
8
+ * pump line-delimited JSON through `fetch`. The dAvePi server's
9
+ * /mcp endpoint runs StreamableHTTPServerTransport in stateless
10
+ * mode, which means each POST is one request-response pair with
11
+ * a JSON body.
12
+ *
13
+ * Each line on stdin is one JSON-RPC message from the agent. We
14
+ * POST it to ${url}/mcp with the bearer token and write the
15
+ * response body to stdout, terminated by a newline (the SDK's
16
+ * stdio transport is line-delimited).
17
+ *
18
+ * Errors surface to the agent as JSON-RPC error responses so the
19
+ * agent sees an actionable failure instead of a silent hang. When
20
+ * the upstream is unreachable we keep the loop alive — the user
21
+ * may have a transient network blip, and exiting would force the
22
+ * agent to restart the proxy.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const readline = require('node:readline');
28
+
29
+ function buildErrorResponse(reqId, code, message, data) {
30
+ // JSON-RPC 2.0 error envelope. -32000 to -32099 is the reserved
31
+ // server-implementation range; we use -32000 for transport errors
32
+ // so a downstream agent can branch on "talk to operator" vs
33
+ // "fix your call".
34
+ return JSON.stringify({
35
+ jsonrpc: '2.0',
36
+ id: reqId === undefined ? null : reqId,
37
+ error: { code, message, ...(data !== undefined ? { data } : {}) },
38
+ });
39
+ }
40
+
41
+ // Default upstream timeout. Long enough that a slow aggregation
42
+ // against a remote /mcp doesn't false-positive, short enough that a
43
+ // stalled connection doesn't block the agent indefinitely. Tunable
44
+ // via DAVEPI_HTTP_TIMEOUT_MS for CI / dev / latency-sensitive setups.
45
+ const DEFAULT_TIMEOUT_MS = 30_000;
46
+
47
+ function getTimeoutMs() {
48
+ const raw = process.env.DAVEPI_HTTP_TIMEOUT_MS;
49
+ if (!raw) return DEFAULT_TIMEOUT_MS;
50
+ const parsed = Number(raw);
51
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_TIMEOUT_MS;
52
+ return parsed;
53
+ }
54
+
55
+ async function forwardOne(line, { url, token }) {
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(line);
59
+ } catch (parseErr) {
60
+ return buildErrorResponse(null, -32700, `Parse error: ${parseErr.message}`);
61
+ }
62
+ const reqId = parsed && Object.prototype.hasOwnProperty.call(parsed, 'id') ? parsed.id : undefined;
63
+
64
+ // AbortController guards both phases — the fetch() promise (TCP
65
+ // connect, headers received) AND response.text() (body streaming).
66
+ // Without this, a stalled upstream that holds the socket open but
67
+ // never sends bytes would block the entire stdio loop and every
68
+ // subsequent message behind it.
69
+ const timeoutMs = getTimeoutMs();
70
+ const controller = new AbortController();
71
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
72
+
73
+ try {
74
+ let response;
75
+ try {
76
+ response = await fetch(`${url.replace(/\/+$/, '')}/mcp`, {
77
+ method: 'POST',
78
+ headers: {
79
+ 'Authorization': `Bearer ${token}`,
80
+ 'Content-Type': 'application/json',
81
+ 'Accept': 'application/json, text/event-stream',
82
+ },
83
+ body: line,
84
+ signal: controller.signal,
85
+ });
86
+ } catch (fetchErr) {
87
+ // AbortError surfaces with .name === 'AbortError' on Node 18+;
88
+ // distinguish it from generic transport errors so the agent
89
+ // can react differently (a timeout is "retry shortly", a
90
+ // network error is "operator's broken").
91
+ if (fetchErr.name === 'AbortError') {
92
+ return buildErrorResponse(
93
+ reqId,
94
+ -32000,
95
+ `Upstream /mcp timed out after ${timeoutMs}ms`,
96
+ { timeoutMs }
97
+ );
98
+ }
99
+ return buildErrorResponse(
100
+ reqId,
101
+ -32000,
102
+ `Transport error contacting ${url}: ${fetchErr.message}`
103
+ );
104
+ }
105
+
106
+ let body;
107
+ try {
108
+ body = await response.text();
109
+ } catch (readErr) {
110
+ if (readErr.name === 'AbortError') {
111
+ return buildErrorResponse(
112
+ reqId,
113
+ -32000,
114
+ `Upstream /mcp body read timed out after ${timeoutMs}ms`,
115
+ { timeoutMs }
116
+ );
117
+ }
118
+ return buildErrorResponse(
119
+ reqId,
120
+ -32000,
121
+ `Transport error reading /mcp body: ${readErr.message}`
122
+ );
123
+ }
124
+
125
+ if (!response.ok) {
126
+ return buildErrorResponse(
127
+ reqId,
128
+ -32000,
129
+ `Upstream /mcp returned ${response.status}`,
130
+ { status: response.status, body: tryParseJson(body) ?? body }
131
+ );
132
+ }
133
+
134
+ // The MCP HTTP transport (per spec) uses Content-Type to pick
135
+ // the response shape:
136
+ // - application/json — body is the raw JSON-RPC message.
137
+ // - text/event-stream — body is one or more SSE events; the
138
+ // `data:` field of each event carries the JSON-RPC payload.
139
+ // The dAvePi server happens to use SSE for typical responses
140
+ // (the SDK's StreamableHTTPServerTransport sends event: message
141
+ // frames), so we parse SSE first and fall back to JSON.
142
+ const contentType = response.headers.get('content-type') || '';
143
+ if (contentType.includes('text/event-stream')) {
144
+ const extracted = extractSseData(body);
145
+ if (extracted) return extracted;
146
+ // SSE body but no parseable data event — surface as a transport
147
+ // error rather than dropping the response on the floor.
148
+ return buildErrorResponse(
149
+ reqId,
150
+ -32000,
151
+ 'Upstream /mcp returned SSE with no data frame',
152
+ { body }
153
+ );
154
+ }
155
+ return body;
156
+ } finally {
157
+ clearTimeout(timer);
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Extract the JSON-RPC payload from an SSE response body. Handles the
163
+ * standard cases (one `event: message` with one `data:` line) and the
164
+ * spec-permitted multi-line `data:` form (concatenated with newlines).
165
+ * Returns null when no `data:` field is present.
166
+ */
167
+ function extractSseData(body) {
168
+ // Per SSE spec, events are separated by a blank line. Each event
169
+ // is a sequence of `field: value` lines. We only care about
170
+ // `data:` for forwarding the JSON-RPC payload upward.
171
+ const events = body.split(/\r?\n\r?\n/);
172
+ for (const event of events) {
173
+ const dataLines = [];
174
+ for (const line of event.split(/\r?\n/)) {
175
+ // Spec: `data: <value>` (the leading space is optional). Lines
176
+ // starting with `:` are comments; everything else is metadata
177
+ // we ignore for forwarding.
178
+ if (line.startsWith('data:')) {
179
+ dataLines.push(line.slice(line.startsWith('data: ') ? 6 : 5));
180
+ }
181
+ }
182
+ if (dataLines.length > 0) {
183
+ // Multi-line data fields are joined with `\n` per spec.
184
+ return dataLines.join('\n');
185
+ }
186
+ }
187
+ return null;
188
+ }
189
+
190
+ function tryParseJson(text) {
191
+ try { return JSON.parse(text); } catch { return undefined; }
192
+ }
193
+
194
+ async function runHttpProxy({ url, token }) {
195
+ if (!url) throw new Error('runHttpProxy: url is required');
196
+ if (!token) throw new Error('runHttpProxy: token is required');
197
+
198
+ const rl = readline.createInterface({
199
+ input: process.stdin,
200
+ crlfDelay: Infinity,
201
+ });
202
+
203
+ for await (const line of rl) {
204
+ if (!line.trim()) continue;
205
+ const out = await forwardOne(line, { url, token });
206
+ process.stdout.write(out + '\n');
207
+ }
208
+ }
209
+
210
+ module.exports = { runHttpProxy, forwardOne, buildErrorResponse, extractSseData };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Local-stdio mode: spawn `davepi mcp` from the user's local install
3
+ * and pipe stdio through unchanged.
4
+ *
5
+ * Why spawn vs in-process: requiring `davepi` directly would boot
6
+ * Mongo, Apollo, and Express in this process — a lot of side effects
7
+ * for a wrapper that just brokers stdio. The davepi CLI already does
8
+ * the right thing in its own process; we just inherit fds.
9
+ *
10
+ * Resolution order for the davepi binary:
11
+ * 1. ./node_modules/.bin/davepi (the project's local install)
12
+ * 2. PATH (a globally installed davepi)
13
+ *
14
+ * If neither resolves, surface a helpful error pointing the user at
15
+ * `npm install davepi` rather than a cryptic ENOENT.
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const path = require('path');
21
+ const fs = require('fs');
22
+ const { spawn } = require('child_process');
23
+
24
+ function findDavepiBin() {
25
+ // 1. Project-local install. Honour the standard node_modules/.bin
26
+ // layout first — that's what the user gets after `npm install
27
+ // davepi` in their dAvePi project.
28
+ const local = path.join(process.cwd(), 'node_modules', '.bin', 'davepi');
29
+ if (fs.existsSync(local)) return local;
30
+
31
+ // Windows ships .cmd shims for the JS launcher; check that too.
32
+ const localCmd = path.join(process.cwd(), 'node_modules', '.bin', 'davepi.cmd');
33
+ if (fs.existsSync(localCmd)) return localCmd;
34
+
35
+ // 2. Fall back to PATH — a globally installed davepi.
36
+ return 'davepi';
37
+ }
38
+
39
+ function runLocalStdio({ schemas } = {}) {
40
+ const bin = findDavepiBin();
41
+ const env = { ...process.env };
42
+ // davepi's bin reads schema/versions from the project root by
43
+ // default. If the user pointed somewhere non-standard via
44
+ // DAVEPI_SCHEMAS, propagate it.
45
+ if (schemas) env.DAVEPI_SCHEMAS = schemas;
46
+
47
+ // The returned Promise represents the child's lifecycle:
48
+ // - rejects on spawn error (ENOENT, EACCES, ...) so the bin's
49
+ // `main().catch()` prints + exits 1.
50
+ // - resolves when the child exits normally (the bin then exits
51
+ // 0). On non-zero exit or signal, we mirror that by exiting
52
+ // this process directly — bin shouldn't proceed after the
53
+ // subprocess has died.
54
+ // The previous design called `resolve()` immediately, which
55
+ // meant any spawn error fired AFTER the bin had already
56
+ // considered the work done — so `npx -y @davepi/mcp` could exit
57
+ // 0 even when davepi wasn't installed.
58
+ return new Promise((resolve, reject) => {
59
+ const child = spawn(bin, ['mcp'], {
60
+ stdio: 'inherit',
61
+ env,
62
+ // Windows needs shell:true to pick up the .cmd shim if `bin`
63
+ // is a bare name. Doesn't affect Unix where `bin` is a full path.
64
+ shell: process.platform === 'win32',
65
+ });
66
+
67
+ let settled = false;
68
+ const settleReject = (err) => {
69
+ if (settled) return;
70
+ settled = true;
71
+ reject(err);
72
+ };
73
+ const settleResolve = () => {
74
+ if (settled) return;
75
+ settled = true;
76
+ resolve();
77
+ };
78
+
79
+ child.on('error', (err) => {
80
+ if (err.code === 'ENOENT') {
81
+ process.stderr.write(
82
+ 'davepi-mcp: could not find the `davepi` binary. ' +
83
+ 'Install dAvePi in this project (`npm install davepi`) ' +
84
+ 'and try again, or set DAVEPI_URL to use HTTP-proxy mode.\n'
85
+ );
86
+ }
87
+ settleReject(err);
88
+ });
89
+
90
+ child.on('exit', (code, signal) => {
91
+ if (signal) {
92
+ // Re-raise the signal in this process so a parent
93
+ // supervisor sees the real cause of death.
94
+ process.kill(process.pid, signal);
95
+ return;
96
+ }
97
+ if (code === 0) {
98
+ settleResolve();
99
+ return;
100
+ }
101
+ // Non-zero exit — mirror the code so CI / supervisors see
102
+ // the real outcome. We exit directly because resolving with
103
+ // a non-zero code wouldn't propagate; the bin doesn't
104
+ // distinguish between "child failed" and "wrapper failed".
105
+ process.exit(code ?? 1);
106
+ });
107
+
108
+ // Forward signals to the child so Ctrl-C from the agent cleanly
109
+ // terminates the davepi server.
110
+ const forward = (sig) => () => {
111
+ try { child.kill(sig); } catch (_) { /* already dead */ }
112
+ };
113
+ process.on('SIGINT', forward('SIGINT'));
114
+ process.on('SIGTERM', forward('SIGTERM'));
115
+ });
116
+ }
117
+
118
+ module.exports = { runLocalStdio, findDavepiBin };
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@davepi/mcp",
3
+ "version": "1.0.0",
4
+ "description": "One-line MCP wiring for dAvePi. Connects Claude Desktop / Cursor / Claude Code to a dAvePi instance — either a remote HTTP /mcp endpoint or a local stdio session.",
5
+ "license": "ISC",
6
+ "homepage": "https://docs.davepi.dev/surfaces/mcp/",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/projik/davepi.git",
10
+ "directory": "packages/mcp"
11
+ },
12
+ "keywords": [
13
+ "davepi",
14
+ "mcp",
15
+ "model-context-protocol",
16
+ "claude",
17
+ "cursor",
18
+ "agent"
19
+ ],
20
+ "bin": {
21
+ "davepi-mcp": "bin/davepi-mcp.js"
22
+ },
23
+ "files": [
24
+ "bin",
25
+ "lib",
26
+ "README.md"
27
+ ],
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "scripts": {
32
+ "test": "node --test test/*.test.js"
33
+ }
34
+ }