@panelwave/mcp 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 PanelWave Project
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,157 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @panelwave/mcp
2
+
3
+ Local [MCP](https://modelcontextprotocol.io) bridge for [PanelWave](https://panelwave.org). It connects Claude Desktop, Claude Code, Cowork, Cursor and other MCP clients to the hosted PanelWave MCP server. It also adds tools that work on files of your computer: find panel artwork in a folder, read a script, and upload a whole folder of artwork into a work.
4
+
5
+ ```
6
+ MCP client ──stdio──▶ @panelwave/mcp ──HTTPS + token──▶ mcp.panelwave.org ──▶ your PanelWave works
7
+ │
8
+ └── pw_local_* tools (only inside PANELWAVE_ALLOWED_DIRS)
9
+ ```
10
+
11
+ The bridge mirrors the hosted server. Tool names, schemas, results and errors pass through unchanged, and its resources are forwarded too. When PanelWave adds or changes a tool, your client picks it up without a bridge update. If you only need the hosted tools and your client supports remote MCP servers, you can connect to `https://mcp.panelwave.org/mcp` directly instead. Uploading local files needs the bridge.
12
+
13
+ ## Quick start
14
+
15
+ 1. Create a personal access token in PanelWave: **Profile → Connected apps & access tokens**. Tokens start with `pw_pat_`. For the script-to-work flow below, tick `works:read`, `works:write`, `assets:read` and `assets:write`.
16
+ 2. Install Node.js 20 or newer.
17
+ 3. Add the bridge to your client as shown below. Set `PANELWAVE_ALLOWED_DIRS` to the folder that holds your scripts and artwork.
18
+
19
+ ### Claude Code
20
+
21
+ ```bash
22
+ claude mcp add panelwave \
23
+ --env PANELWAVE_TOKEN=pw_pat_… \
24
+ --env PANELWAVE_ALLOWED_DIRS="$HOME/comics" \
25
+ -- npx -y @panelwave/mcp
26
+ ```
27
+
28
+ Run `/mcp` inside Claude Code to check that `panelwave` is connected.
29
+
30
+ ### Claude Desktop
31
+
32
+ Edit `claude_desktop_config.json` (Settings → Developer → Edit Config), then restart Claude Desktop:
33
+
34
+ ```json
35
+ {
36
+ "mcpServers": {
37
+ "panelwave": {
38
+ "command": "npx",
39
+ "args": ["-y", "@panelwave/mcp"],
40
+ "env": {
41
+ "PANELWAVE_TOKEN": "pw_pat_…",
42
+ "PANELWAVE_ALLOWED_DIRS": "/Users/me/comics"
43
+ }
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ On Windows, write the folder as `"C:\\Users\\me\\comics"`. If Claude Desktop cannot find `npx`, use `"command": "cmd"` with `"args": ["/c", "npx", "-y", "@panelwave/mcp"]`.
50
+
51
+ ### Cowork
52
+
53
+ Cowork runs inside the Claude Desktop app. Configure the bridge as for Claude Desktop above. Point `PANELWAVE_ALLOWED_DIRS` at the same folder you give Cowork to work in, so both see the same files.
54
+
55
+ ### Cursor
56
+
57
+ Add the server to `~/.cursor/mcp.json`, or to `.cursor/mcp.json` in a project:
58
+
59
+ ```json
60
+ {
61
+ "mcpServers": {
62
+ "panelwave": {
63
+ "command": "npx",
64
+ "args": ["-y", "@panelwave/mcp"],
65
+ "env": { "PANELWAVE_TOKEN": "pw_pat_…", "PANELWAVE_ALLOWED_DIRS": "/Users/me/comics" }
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ ### Other clients
72
+
73
+ Any client that starts stdio servers works the same way. The command is `npx -y @panelwave/mcp`, and the environment variables below are the configuration.
74
+
75
+ ## Walkthrough: from a script and a folder of panels to a work
76
+
77
+ Say `~/comics/rooftop` holds a script and the finished panels:
78
+
79
+ ```
80
+ rooftop/
81
+ script.md (or .fountain / .txt)
82
+ panels/
83
+ panel-01.png
84
+ panel-02.png
85
+ …
86
+ panel-12.jpg
87
+ ```
88
+
89
+ Then ask the assistant, one step at a time or all at once:
90
+
91
+ 1. **"Read rooftop/script.md and turn it into a PanelWave work: one panel per shot, speech balloons from the dialogue, mobile and A4 formats."** The assistant reads the script with `pw_local_read_text` and drafts an outline. It shows you a dry run of `pw_works_create_from_outline`, then creates the work.
92
+ 2. **"Upload the images in rooftop/panels into that work, into a folder called Panels."** `pw_local_upload_files` hashes each file, reads its dimensions and uploads it. Large videos go up in parts. Files already in the library are reused, so running this again after an interruption only uploads what is missing.
93
+ 3. **"Attach panel-01 to the first panel, panel-02 to the second, and so on."** `pw_panels_attach_artwork` sets each image as the panel's background. It fits the panel to the image's aspect ratio on every page format.
94
+ 4. **"Check the work."** `pw_validation_run` lists what is still missing, such as panels without artwork or untranslated balloons. Open the editor link from step 1 to review and publish.
95
+
96
+ The assistant asks before destructive steps such as deleting pages or panels.
97
+
98
+ ## Configuration
99
+
100
+ | Variable | Default | Meaning |
101
+ |----------|---------|---------|
102
+ | `PANELWAVE_TOKEN` | *(required)* | Personal access token. The bridge refuses to start without it. |
103
+ | `PANELWAVE_MCP_URL` | `https://mcp.panelwave.org/mcp` | Hosted endpoint. Plain `http` is accepted only for `localhost`. |
104
+ | `PANELWAVE_ALLOWED_DIRS` | *(none — local tools off)* | Folders the local tools may read, separated by `;` on Windows and `:` on macOS/Linux. Without it the bridge only mirrors the hosted tools. A drive root or your whole home folder is refused. |
105
+
106
+ `panelwave-mcp --version` prints the version and `--help` a short usage. The bridge writes diagnostics to stderr, and your client shows them in its MCP log.
107
+
108
+ ## Local tools
109
+
110
+ | Tool | What it does |
111
+ |------|--------------|
112
+ | `pw_local_list_files` | Lists a folder: absolute path, size, mime type, kind, and for images the pixel dimensions with EXIF orientation applied. Supports a name glob such as `*.{png,jpg}`, subfolders and a limit. Files sort naturally, so `panel-2` comes before `panel-10`. |
113
+ | `pw_local_read_text` | Reads a UTF-8 text file such as a script, `.fountain` screenplay or outline. Files over 1 MiB are cut and flagged `truncated`; `maxBytes` goes up to 5 MiB. Binary files are refused. |
114
+ | `pw_local_upload_files` | Uploads images, videos, audio and fonts into a work's asset library, from a folder with an optional glob or from a list of paths. See below. |
115
+
116
+ Every path is resolved, with symlinks followed, and must stay inside `PANELWAVE_ALLOWED_DIRS`. Anything else fails with `INVALID_INPUT`. The check runs on the path text before the filesystem is touched, and network paths (`\\server\share`) are refused, so a path can never make Windows connect to another machine. Relative paths are taken from the first allowed folder. Hidden files and `node_modules` are skipped in listings unless you ask for them, and `pw_local_read_text` never reads hidden files or folders such as `.ssh` or `.env`. The list and read tools never change anything.
117
+
118
+ Uploads go only to the https storage URLs the hosted server signs (plain http only on localhost, for development), and the bridge never sends its token or cookies there.
119
+
120
+ ### How uploads work
121
+
122
+ - **Deduplicated.** Each file's SHA-256 is compared with the library first. Content that is already there, or appears twice in one batch, is not uploaded again; its row reports the existing asset id. Pass `skipDuplicates: false` to get a `CONFLICT` row instead.
123
+ - **Resumable.** Because finished files are recognised by their hash, running the same upload again after a failure only sends the files that are missing.
124
+ - **Large files in parts.** Files of 10 MB or more are uploaded in 5 MiB parts. Each part is read from disk just before it is sent, so a 2 GB video does not need 2 GB of memory.
125
+ - **Retries.** Each transfer is tried up to 3 times with backoff. An expired upload URL is renewed automatically. An unfinished multipart upload is aborted so storage discards its parts.
126
+ - **Parallel.** Three files are uploaded at a time by default, configurable up to 6 with `concurrency`.
127
+ - **Metadata.** Image width and height are read locally with EXIF orientation applied. Video duration and dimensions are filled in by PanelWave after transcoding.
128
+ - **Folders and tags.** `folderName` files every upload into an asset-library folder and creates it when missing. Use `"Chapter 1/Panels"` for a nested folder. Files that were already in the library are added to the folder too. `tags` are stored on new assets.
129
+ - **Progress.** The tool reports byte progress to clients that show it.
130
+
131
+ The result has one row per file with the status `uploaded`, `exists`, `failed` or `cancelled`, the asset id, and any warnings. An example warning is an image over 5 MB that needs an optimized variant before publishing.
132
+
133
+ ## Errors
134
+
135
+ Tool errors come back as results with `isError: true` and a structured body `{ code, message }`. The codes are the same as on the hosted server:
136
+
137
+ - **`UNAUTHENTICATED`** means PanelWave rejected the token because it expired, was revoked or was mistyped. Create a new token and update the client configuration.
138
+ - **`UPSTREAM_UNAVAILABLE`** means the hosted server or the upload storage is unreachable. The list and read tools keep working. The bridge retries in the background and announces the PanelWave tools once it reaches the server.
139
+ - **`INVALID_INPUT`**, **`NOT_FOUND`** and **`PRECONDITION_FAILED`** come from the local tools, for example for a path outside the allowed folders, a missing file or a binary file.
140
+ - **`QUOTA_EXCEEDED`**, **`PERMISSION_DENIED`** and other hosted codes are passed through unchanged, also inside upload rows.
141
+
142
+ ## Development
143
+
144
+ From the repository root:
145
+
146
+ ```bash
147
+ npm install
148
+ npm run build:mcp
149
+ npm test --workspace=packages/mcp
150
+ MCP_BIG_UPLOAD=1 npm test --workspace=packages/mcp # adds the 100 MB streaming upload test
151
+ ```
152
+
153
+ The tests run the bridge against an in-process stand-in for the hosted server, over both in-memory and Streamable HTTP transports. Uploads are tested against a simulated gateway and a mock S3 endpoint that can fail on demand.
154
+
155
+ ## License
156
+
157
+ MIT
@@ -0,0 +1,34 @@
1
+ import { Server } from '@modelcontextprotocol/server';
2
+ import type { BridgeConfig } from './config';
3
+ import { type LocalTool } from './local-tools';
4
+ import { RemoteProxy } from './remote';
5
+ export declare const LOCAL_INSTRUCTIONS: string;
6
+ export interface BridgeOptions {
7
+ config: BridgeConfig;
8
+ remote: RemoteProxy;
9
+ localTools?: LocalTool[];
10
+ log?: (message: string) => void;
11
+ }
12
+ /**
13
+ * The stdio-facing MCP server: local file tools plus a transparent mirror of
14
+ * the hosted PanelWave server (tool names, schemas, results and errors are
15
+ * passed through untouched).
16
+ */
17
+ export declare function createBridgeServer(opts: BridgeOptions): Server;
18
+ export interface RunningBridge {
19
+ server: Server;
20
+ remote: RemoteProxy;
21
+ close(): Promise<void>;
22
+ }
23
+ /**
24
+ * Build the proxy + server. The remote is contacted once up front (for its
25
+ * instructions and tool list); when that fails the bridge still starts and
26
+ * retries in the background, announcing the remote tools via list_changed
27
+ * once they are reachable.
28
+ */
29
+ export declare function startBridge(config: BridgeConfig, opts?: {
30
+ log?: (message: string) => void;
31
+ remote?: RemoteProxy;
32
+ connectTimeoutMs?: number;
33
+ retryMs?: number;
34
+ }): Promise<RunningBridge>;
package/dist/bridge.js ADDED
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LOCAL_INSTRUCTIONS = void 0;
4
+ exports.createBridgeServer = createBridgeServer;
5
+ exports.startBridge = startBridge;
6
+ const server_1 = require("@modelcontextprotocol/server");
7
+ const errors_1 = require("./errors");
8
+ const local_tools_1 = require("./local-tools");
9
+ const remote_1 = require("./remote");
10
+ exports.LOCAL_INSTRUCTIONS = [
11
+ 'This PanelWave connection runs through the local bridge (@panelwave/mcp).',
12
+ 'Besides the hosted pw_* tools it offers pw_local_* tools that work on files of this computer:',
13
+ 'pw_local_list_files finds panel artwork or scripts in a folder, pw_local_read_text reads a script or outline,',
14
+ 'pw_local_upload_files uploads a folder of artwork into a work (deduplicated, resumable) before pw_panels_attach_artwork.',
15
+ 'Only folders listed in PANELWAVE_ALLOWED_DIRS are readable; paths may be absolute or relative to the first allowed folder.',
16
+ ].join(' ');
17
+ function toTool(t) {
18
+ return {
19
+ name: t.name,
20
+ title: t.title,
21
+ description: t.description,
22
+ inputSchema: t.inputSchema,
23
+ ...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
24
+ annotations: t.annotations,
25
+ };
26
+ }
27
+ /** Rethrow a remote JSON-RPC error unchanged; anything else becomes an internal protocol error. */
28
+ function asProtocolError(e) {
29
+ if (e instanceof server_1.ProtocolError)
30
+ return e;
31
+ if (e instanceof errors_1.BridgeError)
32
+ return new server_1.ProtocolError(server_1.ProtocolErrorCode.InternalError, `${e.code}: ${e.message}`, { code: e.code });
33
+ return new server_1.ProtocolError(server_1.ProtocolErrorCode.InternalError, e instanceof Error ? e.message : String(e));
34
+ }
35
+ /**
36
+ * The stdio-facing MCP server: local file tools plus a transparent mirror of
37
+ * the hosted PanelWave server (tool names, schemas, results and errors are
38
+ * passed through untouched).
39
+ */
40
+ function createBridgeServer(opts) {
41
+ const { config, remote } = opts;
42
+ const log = opts.log ?? (() => undefined);
43
+ // The local tools exist only when folders were allowed explicitly (PANELWAVE_ALLOWED_DIRS).
44
+ const localTools = opts.localTools ?? (config.allowedDirs.length ? local_tools_1.LOCAL_TOOLS : []);
45
+ const localByName = new Map(localTools.map((t) => [t.name, t]));
46
+ const instructions = [remote.instructions, localTools.length ? exports.LOCAL_INSTRUCTIONS : ''].filter(Boolean).join('\n\n');
47
+ const server = new server_1.Server({ name: 'panelwave', title: 'PanelWave', version: config.version }, {
48
+ capabilities: { tools: { listChanged: true }, resources: { listChanged: true } },
49
+ instructions,
50
+ });
51
+ remote.onToolsChanged = () => void server.sendToolListChanged().catch(() => undefined);
52
+ remote.onResourcesChanged = () => void server.sendResourceListChanged().catch(() => undefined);
53
+ server.setRequestHandler('tools/list', async () => {
54
+ let remoteTools = [];
55
+ try {
56
+ remoteTools = await remote.listTools();
57
+ }
58
+ catch (e) {
59
+ log(`remote tools unavailable: ${e instanceof Error ? e.message : String(e)}`);
60
+ }
61
+ return { tools: [...localTools.map(toTool), ...remoteTools.filter((t) => !localByName.has(t.name))] };
62
+ });
63
+ server.setRequestHandler('tools/call', async (request, ctx) => {
64
+ const { name, arguments: args } = request.params;
65
+ const meta = request.params._meta;
66
+ const progressToken = ctx.mcpReq._meta?.progressToken ?? meta?.progressToken;
67
+ const notify = progressToken === undefined
68
+ ? undefined
69
+ : (p) => ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken, ...p } }).catch(() => undefined);
70
+ const local = localByName.get(name);
71
+ if (local) {
72
+ return (await local.call(args, {
73
+ allowedDirs: config.allowedDirs,
74
+ signal: ctx.mcpReq.signal,
75
+ callRemote: (toolName, toolArgs, signal) => remote.callTool(toolName, toolArgs, { signal }),
76
+ progress: async (progress, total, message) => {
77
+ await notify?.({ progress, ...(total !== undefined ? { total } : {}), ...(message ? { message } : {}) });
78
+ },
79
+ }));
80
+ }
81
+ try {
82
+ return await remote.callTool(name, args, {
83
+ signal: ctx.mcpReq.signal,
84
+ onprogress: notify ? (p) => void notify(p) : undefined,
85
+ });
86
+ }
87
+ catch (e) {
88
+ if (e instanceof server_1.ProtocolError)
89
+ throw e;
90
+ if (ctx.mcpReq.signal.aborted)
91
+ return (0, local_tools_1.errorResult)(new errors_1.BridgeError('CANCELLED', 'The call was cancelled.'));
92
+ return (0, local_tools_1.errorResult)(e);
93
+ }
94
+ });
95
+ server.setRequestHandler('resources/list', async () => {
96
+ try {
97
+ return { resources: await remote.listResources() };
98
+ }
99
+ catch (e) {
100
+ log(`remote resources unavailable: ${e instanceof Error ? e.message : String(e)}`);
101
+ return { resources: [] };
102
+ }
103
+ });
104
+ server.setRequestHandler('resources/templates/list', async () => {
105
+ try {
106
+ return { resourceTemplates: await remote.listResourceTemplates() };
107
+ }
108
+ catch (e) {
109
+ log(`remote resource templates unavailable: ${e instanceof Error ? e.message : String(e)}`);
110
+ return { resourceTemplates: [] };
111
+ }
112
+ });
113
+ server.setRequestHandler('resources/read', async (request, ctx) => {
114
+ try {
115
+ return await remote.readResource(request.params.uri, ctx.mcpReq.signal);
116
+ }
117
+ catch (e) {
118
+ throw asProtocolError(e);
119
+ }
120
+ });
121
+ return server;
122
+ }
123
+ /**
124
+ * Build the proxy + server. The remote is contacted once up front (for its
125
+ * instructions and tool list); when that fails the bridge still starts and
126
+ * retries in the background, announcing the remote tools via list_changed
127
+ * once they are reachable.
128
+ */
129
+ async function startBridge(config, opts = {}) {
130
+ const log = opts.log ?? (() => undefined);
131
+ const remote = opts.remote ?? new remote_1.RemoteProxy({ url: config.url, token: config.token, version: config.version, log });
132
+ /** 'connected', 'fatal' (bad token — retrying cannot help) or 'retry'. */
133
+ const tryConnect = async () => {
134
+ let timer;
135
+ const timeout = new Promise((_, reject) => {
136
+ timer = setTimeout(() => reject(new errors_1.BridgeError('UPSTREAM_UNAVAILABLE', 'connect timed out')), opts.connectTimeoutMs ?? 15_000);
137
+ });
138
+ try {
139
+ await Promise.race([remote.connect().then(() => remote.listTools()), timeout]);
140
+ return 'connected';
141
+ }
142
+ catch (e) {
143
+ const err = e instanceof errors_1.BridgeError ? e : new errors_1.BridgeError('UPSTREAM_UNAVAILABLE', e instanceof Error ? e.message : String(e));
144
+ log(`PanelWave MCP server not reachable: ${err.message}`);
145
+ return err.code === 'UNAUTHENTICATED' ? 'fatal' : 'retry';
146
+ }
147
+ finally {
148
+ clearTimeout(timer);
149
+ }
150
+ };
151
+ const first = await tryConnect();
152
+ const server = createBridgeServer({ config, remote, log });
153
+ let retry;
154
+ if (first === 'retry') {
155
+ retry = setInterval(() => {
156
+ void tryConnect().then((state) => {
157
+ if (state === 'retry' || !retry)
158
+ return;
159
+ clearInterval(retry);
160
+ retry = undefined;
161
+ if (state === 'connected') {
162
+ log('connected to the PanelWave MCP server');
163
+ void server.sendToolListChanged().catch(() => undefined);
164
+ void server.sendResourceListChanged().catch(() => undefined);
165
+ }
166
+ });
167
+ }, opts.retryMs ?? 30_000);
168
+ retry.unref();
169
+ }
170
+ return {
171
+ server,
172
+ remote,
173
+ async close() {
174
+ if (retry)
175
+ clearInterval(retry);
176
+ await remote.close();
177
+ await server.close().catch(() => undefined);
178
+ },
179
+ };
180
+ }
@@ -0,0 +1,17 @@
1
+ /** Package version — package.json sits one level above both src/ and dist/. */
2
+ export declare const version: string;
3
+ /** Bridge configuration from the environment the MCP client starts it with. */
4
+ export interface BridgeConfig {
5
+ /** The hosted PanelWave MCP endpoint. */
6
+ url: string;
7
+ /** Personal access token (or MCP access token) sent as the bearer credential. */
8
+ token: string;
9
+ /** Absolute directories the local file tools may read. */
10
+ allowedDirs: string[];
11
+ version: string;
12
+ }
13
+ export declare const DEFAULT_URL = "https://mcp.panelwave.org/mcp";
14
+ export declare class ConfigError extends Error {
15
+ constructor(message: string);
16
+ }
17
+ export declare function loadConfig(env?: NodeJS.ProcessEnv, cwd?: string): BridgeConfig;
package/dist/config.js ADDED
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ConfigError = exports.DEFAULT_URL = exports.version = void 0;
37
+ exports.loadConfig = loadConfig;
38
+ const fs = __importStar(require("node:fs"));
39
+ const os = __importStar(require("node:os"));
40
+ const path = __importStar(require("node:path"));
41
+ /** Package version — package.json sits one level above both src/ and dist/. */
42
+ exports.version = (() => {
43
+ try {
44
+ return JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version ?? '0.0.0';
45
+ }
46
+ catch {
47
+ return '0.0.0';
48
+ }
49
+ })();
50
+ exports.DEFAULT_URL = 'https://mcp.panelwave.org/mcp';
51
+ class ConfigError extends Error {
52
+ constructor(message) {
53
+ super(message);
54
+ this.name = 'ConfigError';
55
+ }
56
+ }
57
+ exports.ConfigError = ConfigError;
58
+ function loadConfig(env = process.env, cwd = process.cwd()) {
59
+ const token = (env.PANELWAVE_TOKEN ?? '').trim();
60
+ if (!token) {
61
+ throw new ConfigError('PANELWAVE_TOKEN is not set. Create a personal access token in PanelWave (Profile → Connected apps & access tokens) and add it to the "env" of this MCP server in your client configuration.');
62
+ }
63
+ const url = (env.PANELWAVE_MCP_URL ?? '').trim() || exports.DEFAULT_URL;
64
+ let parsed;
65
+ try {
66
+ parsed = new URL(url);
67
+ }
68
+ catch {
69
+ throw new ConfigError(`PANELWAVE_MCP_URL is not a URL: ${url}`);
70
+ }
71
+ if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname))) {
72
+ throw new ConfigError('PANELWAVE_MCP_URL must use https (plain http only for localhost).');
73
+ }
74
+ const dirs = (env.PANELWAVE_ALLOWED_DIRS ?? '')
75
+ .split(path.delimiter)
76
+ .map((d) => d.trim())
77
+ .filter(Boolean);
78
+ // No default folder: without PANELWAVE_ALLOWED_DIRS the local tools are off. Clients often start stdio
79
+ // servers in the home folder or "/", which would expose everything (security review 2026-09-30).
80
+ const allowedDirs = dirs.map((d) => path.resolve(cwd, d));
81
+ const home = path.resolve(os.homedir());
82
+ const same = (a, b) => (process.platform === 'win32' || process.platform === 'darwin' ? a.toLowerCase() === b.toLowerCase() : a === b);
83
+ for (const d of allowedDirs) {
84
+ if (path.parse(d).root === d || same(d.replace(/[\\/]+$/, ''), home)) {
85
+ throw new ConfigError(`PANELWAVE_ALLOWED_DIRS must name the folders with your comics, not a drive root or your whole home folder (${d}).`);
86
+ }
87
+ }
88
+ return { url: parsed.toString(), token, allowedDirs, version: exports.version };
89
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Error codes of the local tools — the same vocabulary the hosted gateway
3
+ * uses, so a remote tool error re-raised by a local tool keeps its code.
4
+ */
5
+ export declare const BRIDGE_ERROR_CODES: readonly ["INVALID_INPUT", "NOT_FOUND", "PERMISSION_DENIED", "UNAUTHENTICATED", "QUOTA_EXCEEDED", "CONFLICT", "RATE_LIMITED", "CONFIRMATION_REQUIRED", "CANCELLED", "PRECONDITION_FAILED", "UPSTREAM_UNAVAILABLE", "FEATURE_DISABLED", "INTERNAL"];
6
+ export type BridgeErrorCode = (typeof BRIDGE_ERROR_CODES)[number];
7
+ export declare class BridgeError extends Error {
8
+ readonly code: BridgeErrorCode;
9
+ readonly details?: unknown | undefined;
10
+ constructor(code: BridgeErrorCode, message: string, details?: unknown | undefined);
11
+ }
12
+ export declare function isBridgeErrorCode(code: unknown): code is BridgeErrorCode;
13
+ export declare function toBridgeError(e: unknown): BridgeError;
package/dist/errors.js ADDED
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BridgeError = exports.BRIDGE_ERROR_CODES = void 0;
4
+ exports.isBridgeErrorCode = isBridgeErrorCode;
5
+ exports.toBridgeError = toBridgeError;
6
+ /**
7
+ * Error codes of the local tools — the same vocabulary the hosted gateway
8
+ * uses, so a remote tool error re-raised by a local tool keeps its code.
9
+ */
10
+ exports.BRIDGE_ERROR_CODES = [
11
+ 'INVALID_INPUT',
12
+ 'NOT_FOUND',
13
+ 'PERMISSION_DENIED',
14
+ 'UNAUTHENTICATED',
15
+ 'QUOTA_EXCEEDED',
16
+ 'CONFLICT',
17
+ 'RATE_LIMITED',
18
+ 'CONFIRMATION_REQUIRED',
19
+ 'CANCELLED',
20
+ 'PRECONDITION_FAILED',
21
+ 'UPSTREAM_UNAVAILABLE',
22
+ 'FEATURE_DISABLED',
23
+ 'INTERNAL',
24
+ ];
25
+ class BridgeError extends Error {
26
+ code;
27
+ details;
28
+ constructor(code, message, details) {
29
+ super(message);
30
+ this.code = code;
31
+ this.details = details;
32
+ this.name = 'BridgeError';
33
+ }
34
+ }
35
+ exports.BridgeError = BridgeError;
36
+ function isBridgeErrorCode(code) {
37
+ return typeof code === 'string' && exports.BRIDGE_ERROR_CODES.includes(code);
38
+ }
39
+ function toBridgeError(e) {
40
+ if (e instanceof BridgeError)
41
+ return e;
42
+ const err = e;
43
+ if (err?.name === 'AbortError')
44
+ return new BridgeError('CANCELLED', 'The call was cancelled.');
45
+ if (err?.code === 'ENOENT')
46
+ return new BridgeError('NOT_FOUND', 'No such file or folder.');
47
+ if (err?.code === 'EACCES' || err?.code === 'EPERM')
48
+ return new BridgeError('PRECONDITION_FAILED', 'The file cannot be read (permission denied).');
49
+ return new BridgeError('INTERNAL', e instanceof Error ? e.message : String(e));
50
+ }
@@ -0,0 +1,65 @@
1
+ export type FileKind = 'image' | 'video' | 'audio' | 'font' | 'text' | 'other';
2
+ export declare function mimeOf(filename: string): string;
3
+ /** Asset kind as the gateway understands it (image/video/audio/font), plus text and other. */
4
+ export declare function kindOfMime(mime: string): FileKind;
5
+ /**
6
+ * Compile a file-name glob: `*` (any run), `?` (one char), `{a,b}` (alternatives),
7
+ * matched case-insensitively against the file name. Several patterns may be
8
+ * given separated by `;`.
9
+ */
10
+ export declare function globToRegExp(glob: string): RegExp;
11
+ export declare function matchesGlob(name: string, glob: string | undefined): boolean;
12
+ export interface ImageDimensions {
13
+ width: number;
14
+ height: number;
15
+ /** EXIF orientation when present (1–8). */
16
+ orientation?: number;
17
+ }
18
+ /**
19
+ * Display dimensions of an image file. EXIF orientations 5–8 rotate the image
20
+ * by 90°, so width and height are swapped to match what viewers show.
21
+ * Returns undefined for files image-size cannot read (and for SVGs without size).
22
+ */
23
+ export declare function imageDimensions(filePath: string): Promise<ImageDimensions | undefined>;
24
+ /** Natural sort (panel-2 before panel-10), case-insensitive. */
25
+ export declare function naturalCompare(a: string, b: string): number;
26
+ export declare function looksBinary(buf: Uint8Array): boolean;
27
+ /**
28
+ * Decode UTF-8 text, dropping a BOM and — when the buffer was cut — a partial
29
+ * multi-byte sequence at the end.
30
+ */
31
+ export declare function decodeUtf8(buf: Uint8Array, cut: boolean): string;
32
+ export declare function readHead(filePath: string, maxBytes: number): Promise<{
33
+ buf: Buffer;
34
+ sizeBytes: number;
35
+ }>;
36
+ export declare const MAX_DEPTH = 12;
37
+ export declare const SKIP_DIRS: Set<string>;
38
+ export interface ScanOptions {
39
+ /** File-name glob (see globToRegExp). */
40
+ glob?: string;
41
+ /** Extra filter applied after the glob (e.g. "media files only"). */
42
+ accept?: (name: string) => boolean;
43
+ recursive?: boolean;
44
+ includeHidden?: boolean;
45
+ limit: number;
46
+ signal?: AbortSignal;
47
+ }
48
+ export interface ScannedFile {
49
+ abs: string;
50
+ /** Relative to the scanned folder, forward slashes. */
51
+ rel: string;
52
+ size: number;
53
+ mtime: Date;
54
+ }
55
+ /**
56
+ * Walk a folder inside the sandbox: natural order, subfolders after the files
57
+ * of a folder, symlinks followed only when their target is allowed too,
58
+ * node_modules/.git and (unless asked) dot entries skipped.
59
+ */
60
+ export declare function scanFolder(folder: string, allowedDirs: string[], opts: ScanOptions): Promise<{
61
+ root: string;
62
+ found: ScannedFile[];
63
+ skipped: string[];
64
+ truncated: boolean;
65
+ }>;