This commit is contained in:
Chris Wanstrath 2026-03-01 21:58:50 -08:00
commit 92e7732ab4
17 changed files with 1311 additions and 0 deletions

39
.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
data/
.toes
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
.sandlot/

1
.npmrc Normal file
View File

@ -0,0 +1 @@
registry=https://npm.nose.space

38
README.md Normal file
View File

@ -0,0 +1,38 @@
# mcp.txt
A minimal MCP server for managing text files from Claude (web, desktop, and iOS).
## Setup
```bash
bun install
```
## Usage
```bash
DATA_DIR=~/Documents/claude-files PORT=3100 bun start
```
- `DATA_DIR` — directory the server reads/writes from (default: `~/Documents/claude-files`)
- `PORT` — HTTP port (default: `3001`)
## Tools
| Tool | Description |
|------|-------------|
| `list_files` | List files and directories in a given path |
| `read_file` | Read the contents of a text file |
| `create_file` | Create a new file (fails if it already exists) |
| `edit_file` | Replace the contents of an existing file |
All paths are relative to `DATA_DIR`. Path traversal is rejected. There is no delete — by design.
## Connect to Claude
1. Run the server
2. Expose via HTTPS tunnel (e.g. Sneakers, ngrok, Cloudflare Tunnel)
3. Claude.ai → Settings → Integrations → Add Custom Integration
4. Set the URL to `https://your-tunnel/mcp`
Works on web, desktop, and iOS.

222
bun.lock Normal file
View File

@ -0,0 +1,222 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "mcp.txt",
"dependencies": {
"@because/forge": "^0.0.3",
"@because/hype": "^0.0.6",
"@modelcontextprotocol/sdk": "^1.26.0",
"marked": "^17.0.3",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5.9.3",
},
},
},
"packages": {
"@because/forge": ["@because/forge@0.0.3", "https://npm.nose.space/@because/forge/-/forge-0.0.3.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-3V2be2vkkW1qZH6WSurfYBgyVjot/GcyOt5LfIVyEYQ5cAq6bWbtxsXs5CK/sT8OqbvCu3VHc2k2OXOC6Q3feg=="],
"@because/hype": ["@because/hype@0.0.6", "https://npm.nose.space/@because/hype/-/hype-0.0.6.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-WSRPNoeTBR3nRcPTqfbu6+FUaNenCo/sN/CB2Ism7oiJwTap1i+1AlWPa+MF1eMQlNd2AYRlA3AAu6F52j6/fA=="],
"@hono/node-server": ["@hono/node-server@1.19.9", "https://npm.nose.space/@hono/node-server/-/node-server-1.19.9.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "https://npm.nose.space/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="],
"@types/bun": ["@types/bun@1.3.9", "https://npm.nose.space/@types/bun/-/bun-1.3.9.tgz", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.2.3", "https://npm.nose.space/@types/node/-/node-25.2.3.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
"accepts": ["accepts@2.0.0", "https://npm.nose.space/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"ajv": ["ajv@8.18.0", "https://npm.nose.space/ajv/-/ajv-8.18.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-formats": ["ajv-formats@3.0.1", "https://npm.nose.space/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"body-parser": ["body-parser@2.2.2", "https://npm.nose.space/body-parser/-/body-parser-2.2.2.tgz", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"bytes": ["bytes@3.1.2", "https://npm.nose.space/bytes/-/bytes-3.1.2.tgz", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://npm.nose.space/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "https://npm.nose.space/call-bound/-/call-bound-1.0.4.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"content-disposition": ["content-disposition@1.0.1", "https://npm.nose.space/content-disposition/-/content-disposition-1.0.1.tgz", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
"content-type": ["content-type@1.0.5", "https://npm.nose.space/content-type/-/content-type-1.0.5.tgz", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "https://npm.nose.space/cookie/-/cookie-0.7.2.tgz", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "https://npm.nose.space/cookie-signature/-/cookie-signature-1.2.2.tgz", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.6", "https://npm.nose.space/cors/-/cors-2.8.6.tgz", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cross-spawn": ["cross-spawn@7.0.6", "https://npm.nose.space/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "https://npm.nose.space/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"depd": ["depd@2.0.0", "https://npm.nose.space/depd/-/depd-2.0.0.tgz", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dunder-proto": ["dunder-proto@1.0.1", "https://npm.nose.space/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "https://npm.nose.space/ee-first/-/ee-first-1.1.1.tgz", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "https://npm.nose.space/encodeurl/-/encodeurl-2.0.0.tgz", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "https://npm.nose.space/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "https://npm.nose.space/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "https://npm.nose.space/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"escape-html": ["escape-html@1.0.3", "https://npm.nose.space/escape-html/-/escape-html-1.0.3.tgz", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "https://npm.nose.space/etag/-/etag-1.8.1.tgz", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventsource": ["eventsource@3.0.7", "https://npm.nose.space/eventsource/-/eventsource-3.0.7.tgz", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "https://npm.nose.space/eventsource-parser/-/eventsource-parser-3.0.6.tgz", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"express": ["express@5.2.1", "https://npm.nose.space/express/-/express-5.2.1.tgz", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.2.1", "https://npm.nose.space/express-rate-limit/-/express-rate-limit-8.2.1.tgz", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://npm.nose.space/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.0", "https://npm.nose.space/fast-uri/-/fast-uri-3.1.0.tgz", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"finalhandler": ["finalhandler@2.1.1", "https://npm.nose.space/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"forwarded": ["forwarded@0.2.0", "https://npm.nose.space/forwarded/-/forwarded-0.2.0.tgz", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "https://npm.nose.space/fresh/-/fresh-2.0.0.tgz", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"function-bind": ["function-bind@1.1.2", "https://npm.nose.space/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "https://npm.nose.space/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "https://npm.nose.space/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "https://npm.nose.space/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "https://npm.nose.space/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "https://npm.nose.space/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hono": ["hono@4.11.10", "https://npm.nose.space/hono/-/hono-4.11.10.tgz", {}, "sha512-kyWP5PAiMooEvGrA9jcD3IXF7ATu8+o7B3KCbPXid5se52NPqnOpM/r9qeW2heMnOekF4kqR1fXJqCYeCLKrZg=="],
"http-errors": ["http-errors@2.0.1", "https://npm.nose.space/http-errors/-/http-errors-2.0.1.tgz", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "https://npm.nose.space/iconv-lite/-/iconv-lite-0.7.2.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"inherits": ["inherits@2.0.4", "https://npm.nose.space/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["ip-address@10.0.1", "https://npm.nose.space/ip-address/-/ip-address-10.0.1.tgz", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "https://npm.nose.space/ipaddr.js/-/ipaddr.js-1.9.1.tgz", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-promise": ["is-promise@4.0.0", "https://npm.nose.space/is-promise/-/is-promise-4.0.0.tgz", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"isexe": ["isexe@2.0.0", "https://npm.nose.space/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jose": ["jose@6.1.3", "https://npm.nose.space/jose/-/jose-6.1.3.tgz", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "https://npm.nose.space/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "https://npm.nose.space/json-schema-typed/-/json-schema-typed-8.0.2.tgz", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"marked": ["marked@17.0.3", "https://npm.nose.space/marked/-/marked-17.0.3.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "https://npm.nose.space/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "https://npm.nose.space/media-typer/-/media-typer-1.1.0.tgz", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "https://npm.nose.space/merge-descriptors/-/merge-descriptors-2.0.0.tgz", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mime-db": ["mime-db@1.54.0", "https://npm.nose.space/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "https://npm.nose.space/mime-types/-/mime-types-3.0.2.tgz", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"ms": ["ms@2.1.3", "https://npm.nose.space/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@1.0.0", "https://npm.nose.space/negotiator/-/negotiator-1.0.0.tgz", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"object-assign": ["object-assign@4.1.1", "https://npm.nose.space/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "https://npm.nose.space/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "https://npm.nose.space/on-finished/-/on-finished-2.4.1.tgz", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "https://npm.nose.space/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"parseurl": ["parseurl@1.3.3", "https://npm.nose.space/parseurl/-/parseurl-1.3.3.tgz", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "https://npm.nose.space/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@8.3.0", "https://npm.nose.space/path-to-regexp/-/path-to-regexp-8.3.0.tgz", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "https://npm.nose.space/pkce-challenge/-/pkce-challenge-5.0.1.tgz", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"proxy-addr": ["proxy-addr@2.0.7", "https://npm.nose.space/proxy-addr/-/proxy-addr-2.0.7.tgz", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.15.0", "https://npm.nose.space/qs/-/qs-6.15.0.tgz", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
"range-parser": ["range-parser@1.2.1", "https://npm.nose.space/range-parser/-/range-parser-1.2.1.tgz", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "https://npm.nose.space/raw-body/-/raw-body-3.0.2.tgz", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"require-from-string": ["require-from-string@2.0.2", "https://npm.nose.space/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"router": ["router@2.2.0", "https://npm.nose.space/router/-/router-2.2.0.tgz", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "https://npm.nose.space/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"send": ["send@1.2.1", "https://npm.nose.space/send/-/send-1.2.1.tgz", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "https://npm.nose.space/serve-static/-/serve-static-2.2.1.tgz", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"setprototypeof": ["setprototypeof@1.2.0", "https://npm.nose.space/setprototypeof/-/setprototypeof-1.2.0.tgz", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shebang-command": ["shebang-command@2.0.0", "https://npm.nose.space/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "https://npm.nose.space/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"side-channel": ["side-channel@1.1.0", "https://npm.nose.space/side-channel/-/side-channel-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "https://npm.nose.space/side-channel-list/-/side-channel-list-1.0.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "https://npm.nose.space/side-channel-map/-/side-channel-map-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://npm.nose.space/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"statuses": ["statuses@2.0.2", "https://npm.nose.space/statuses/-/statuses-2.0.2.tgz", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"toidentifier": ["toidentifier@1.0.1", "https://npm.nose.space/toidentifier/-/toidentifier-1.0.1.tgz", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"type-is": ["type-is@2.0.1", "https://npm.nose.space/type-is/-/type-is-2.0.1.tgz", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unpipe": ["unpipe@1.0.0", "https://npm.nose.space/unpipe/-/unpipe-1.0.0.tgz", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"vary": ["vary@1.1.2", "https://npm.nose.space/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"which": ["which@2.0.2", "https://npm.nose.space/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrappy": ["wrappy@1.0.2", "https://npm.nose.space/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"zod": ["zod@4.3.6", "https://npm.nose.space/zod/-/zod-4.3.6.tgz", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "https://npm.nose.space/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
}
}

89
index.html Normal file
View File

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html>
<head>
<title>mcp.txt file browser</title>
</head>
<body>
<h1>mcp.txt file browser</h1>
<p>Path: <code id="current-path">/</code></p>
<div id="listing"></div>
<hr>
<pre id="file-content" hidden></pre>
<script>
let currentPath = ".";
async function navigate(path) {
currentPath = path;
document.getElementById("current-path").textContent = path === "." ? "/" : "/" + path;
document.getElementById("file-content").hidden = true;
const res = await fetch("/api/list?path=" + encodeURIComponent(path));
const data = await res.json();
if (data.error) {
document.getElementById("listing").textContent = "Error: " + data.error;
return;
}
const listing = document.getElementById("listing");
listing.innerHTML = "";
const ul = document.createElement("ul");
// parent directory link
if (path !== ".") {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = "#";
a.textContent = "..";
const parent = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : ".";
a.onclick = (e) => { e.preventDefault(); navigate(parent); };
li.appendChild(a);
ul.appendChild(li);
}
for (const entry of data.entries) {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = "#";
a.textContent = entry.name + (entry.type === "directory" ? "/" : "");
const entryPath = path === "." ? entry.name : path + "/" + entry.name;
if (entry.type === "directory") {
a.onclick = (e) => { e.preventDefault(); navigate(entryPath); };
} else {
a.onclick = (e) => { e.preventDefault(); readFile(entryPath); };
li.append(" (" + entry.size + " bytes)");
}
li.prepend(a);
ul.appendChild(li);
}
if (data.entries.length === 0) {
listing.textContent = "(empty directory)";
} else {
listing.appendChild(ul);
}
}
async function readFile(path) {
const pre = document.getElementById("file-content");
pre.hidden = false;
pre.textContent = "Loading...";
const res = await fetch("/api/read?path=" + encodeURIComponent(path));
const data = await res.json();
if (data.error) {
pre.textContent = "Error: " + data.error;
return;
}
pre.textContent = "=== " + path + " ===\n\n" + data.content;
}
navigate(".");
</script>
</body>
</html>

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "mcp.txt",
"module": "src/server/index.ts",
"type": "module",
"private": true,
"scripts": {
"start": "bun run src/server/index.ts",
"dev": "bun run --hot src/server/index.ts",
"toes": "bun run --hot src/server/index.ts"
},
"toes": {
"icon": "📝"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.9.3"
},
"dependencies": {
"@because/forge": "^0.0.3",
"@because/hype": "^0.0.6",
"@modelcontextprotocol/sdk": "^1.26.0",
"marked": "^17.0.3"
}
}

82
src/client/edit.ts Normal file
View File

@ -0,0 +1,82 @@
const editor = document.getElementById("editor") as HTMLTextAreaElement;
const backLink = document.getElementById("back-link") as HTMLAnchorElement;
const saveBtn = document.getElementById("save-btn") as HTMLButtonElement;
const status = document.getElementById("status")!;
const path = decodeURIComponent(window.location.pathname.replace(/^\/edit\//, "")) || null;
// Set back link to return to the file view
if (path) {
backLink.href = "/" + path.split("/").map(encodeURIComponent).join("/");
}
let savedContent = "";
if (!path) {
editor.value = "Error: no file path specified";
editor.disabled = true;
} else {
const res = await fetch("/api/read?path=" + encodeURIComponent(path));
const data = await res.json();
if (data.error) {
editor.value = "Error: " + data.error;
editor.disabled = true;
} else {
savedContent = data.content;
editor.value = data.content;
saveBtn.disabled = true;
}
}
const isDirty = () => editor.value !== savedContent;
editor.addEventListener("input", () => {
const dirty = isDirty();
saveBtn.disabled = !dirty;
status.textContent = dirty ? "Unsaved changes" : "";
});
window.addEventListener("beforeunload", (e) => {
if (isDirty()) e.preventDefault();
});
async function save() {
if (!path) return;
saveBtn.disabled = true;
status.textContent = "Saving...";
try {
const res = await fetch("/api/edit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, content: editor.value }),
});
const data = await res.json();
if (data.error) {
status.textContent = "Error: " + data.error;
saveBtn.disabled = false;
} else {
savedContent = editor.value;
window.location.href = "/" + path.split("/").map(encodeURIComponent).join("/");
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to save";
status.textContent = "Error: " + message;
saveBtn.disabled = false;
}
}
saveBtn.addEventListener("click", save);
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (!saveBtn.disabled) save();
}
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
if (!saveBtn.disabled) save();
}
});

51
src/client/file.ts Normal file
View File

@ -0,0 +1,51 @@
import { marked } from "marked";
const content = document.getElementById("file-content")!;
const markdownContent = document.getElementById("markdown-content")!;
const backLink = document.getElementById("back-link") as HTMLAnchorElement;
const deleteBtn = document.getElementById("delete-btn") as HTMLButtonElement;
const path = decodeURIComponent(window.location.pathname.slice(1)) || null;
const isMarkdown = path?.endsWith(".md") || path?.endsWith(".markdown");
// Set back link to return to the parent directory
if (path && path.includes("/")) {
const dir = path.substring(0, path.lastIndexOf("/"));
backLink.href = "/?dir=" + encodeURIComponent(dir);
} else {
backLink.href = "/";
}
if (!path) {
content.textContent = "Error: no file path specified";
} else {
const res = await fetch("/api/read?path=" + encodeURIComponent(path));
const data = await res.json();
if (data.error) {
content.textContent = "Error: " + data.error;
} else if (isMarkdown) {
content.style.display = "none";
markdownContent.style.display = "block";
markdownContent.innerHTML = await marked(data.content);
} else {
content.textContent = data.content;
}
}
deleteBtn.addEventListener("click", async () => {
if (!path) return;
if (!confirm(`Delete "${path}"? This cannot be undone.`)) return;
const res = await fetch("/api/files/" + encodeURIComponent(path), {
method: "DELETE",
});
const data = await res.json();
if (data.error) {
alert("Delete failed: " + data.error);
} else {
window.location.href = backLink.href;
}
});

173
src/client/main.ts Normal file
View File

@ -0,0 +1,173 @@
let currentPath = ".";
const listingEl = document.getElementById("listing")!;
const newFileBtn = document.getElementById("new-file-btn")!;
const newFileDialog = document.getElementById(
"new-file-dialog",
) as HTMLDialogElement;
const closeNewBtn = document.getElementById("close-new-dialog")!;
const newFileForm = document.getElementById(
"new-file-form",
) as HTMLFormElement;
const newFileName = document.getElementById(
"new-file-name",
) as HTMLInputElement;
const newFileContentInput = document.getElementById(
"new-file-content",
) as HTMLTextAreaElement;
const newFileError = document.getElementById("new-file-error")!;
newFileBtn.addEventListener("click", () => {
newFileName.value = "";
newFileContentInput.value = "";
newFileError.style.display = "none";
newFileDialog.showModal();
});
closeNewBtn.addEventListener("click", () => newFileDialog.close());
newFileDialog.addEventListener("keydown", (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
newFileForm.requestSubmit();
}
});
newFileForm.addEventListener("submit", async (e: Event) => {
e.preventDefault();
newFileError.style.display = "none";
const name = newFileName.value.trim();
if (!name) {
newFileError.textContent = "Filename is required.";
newFileError.style.display = "block";
return;
}
const path = currentPath === "." ? name : currentPath + "/" + name;
try {
const res = await fetch("/api/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, content: newFileContentInput.value }),
});
const data = await res.json();
if (data.error) {
newFileError.textContent = data.error;
newFileError.style.display = "block";
return;
}
newFileDialog.close();
window.location.href = "/" + path.split("/").map(encodeURIComponent).join("/");
} catch (err: any) {
newFileError.textContent = err.message || "Failed to create file.";
newFileError.style.display = "block";
}
});
async function navigate(path: string) {
currentPath = path;
const res = await fetch("/api/list?path=" + encodeURIComponent(path));
const data = await res.json();
if (data.error) {
listingEl.innerHTML = "<p>Error: " + data.error + "</p>";
return;
}
if (data.entries.length === 0) {
listingEl.innerHTML = "<p><em>Empty directory</em></p>";
return;
}
// Sort: directories first, then files
const sorted = data.entries.sort((a: any, b: any) => {
if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
return a.name.localeCompare(b.name);
});
const table = document.createElement("table");
table.setAttribute("role", "grid");
table.innerHTML =
"<thead><tr><th>Name</th><th>Size</th><th>Modified</th></tr></thead>";
const tbody = document.createElement("tbody");
// Parent directory row
if (path !== ".") {
const tr = document.createElement("tr");
const td = document.createElement("td");
td.setAttribute("colspan", "3");
const a = document.createElement("a");
a.href = "#";
a.textContent = "..";
const parent = path.includes("/")
? path.substring(0, path.lastIndexOf("/"))
: ".";
a.addEventListener("click", (e: Event) => {
e.preventDefault();
navigate(parent);
});
td.appendChild(a);
tr.appendChild(td);
tbody.appendChild(tr);
}
for (const entry of sorted) {
const tr = document.createElement("tr");
const entryPath = path === "." ? entry.name : path + "/" + entry.name;
const nameTd = document.createElement("td");
const a = document.createElement("a");
a.href = "#";
a.textContent =
entry.type === "directory" ? entry.name + "/" : entry.name;
if (entry.type === "directory") {
a.addEventListener("click", (e: Event) => {
e.preventDefault();
navigate(entryPath);
});
} else {
a.addEventListener("click", (e: Event) => {
e.preventDefault();
readFile(entryPath);
});
}
nameTd.appendChild(a);
tr.appendChild(nameTd);
const sizeTd = document.createElement("td");
sizeTd.textContent =
entry.type === "file" ? formatSize(entry.size) : "";
tr.appendChild(sizeTd);
const modTd = document.createElement("td");
modTd.textContent = entry.modified
? new Date(entry.modified).toLocaleString()
: "";
tr.appendChild(modTd);
tbody.appendChild(tr);
}
table.appendChild(tbody);
listingEl.innerHTML = "";
listingEl.appendChild(table);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
}
function readFile(path: string) {
window.location.href = "/" + path.split("/").map(encodeURIComponent).join("/");
}
const params = new URLSearchParams(window.location.search);
navigate(params.get("dir") || ".");

71
src/pages/_edit.tsx Normal file
View File

@ -0,0 +1,71 @@
import { define, Styles } from "@because/forge";
const BackLink = define("EditBackLink", {
base: "a",
display: "inline-block",
marginBottom: 16,
});
const Editor = define("Editor", {
base: "textarea",
width: "100%",
minHeight: 400,
fontFamily: "monospace",
fontSize: 14,
resize: "vertical",
});
const Toolbar = define("EditToolbar", {
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 12,
});
const SaveBtn = define("SaveBtn", {
base: "button",
padding: "8px 16px",
borderRadius: 4,
cursor: "pointer",
fontSize: "inherit",
lineHeight: "inherit",
background: "var(--pico-primary-background)",
border: "1px solid var(--pico-primary-background)",
color: "var(--pico-primary-inverse)",
states: {
hover: { background: "var(--pico-primary-hover-background)" },
disabled: { opacity: "0.5", cursor: "default" },
},
});
const Status = define("EditStatus", {
base: "span",
fontSize: 14,
color: "var(--pico-muted-color)",
});
export default ({ req }: any) => {
const path = req.param("path") || "";
return (
<section>
<Styles />
<BackLink id="back-link" href={`/${path.split("/").map(encodeURIComponent).join("/")}`}>
&larr; Back to file
</BackLink>
<h3 id="file-title">Editing: {path}</h3>
<Editor id="editor" spellcheck={false}>Loading...</Editor>
<Toolbar>
<SaveBtn id="save-btn" disabled>Save</SaveBtn>
<Status id="status"></Status>
</Toolbar>
<script
type="module"
dangerouslySetInnerHTML={{
__html: `import "/client/edit.ts"`,
}}
/>
</section>
);
};

102
src/pages/_file.tsx Normal file
View File

@ -0,0 +1,102 @@
import { define, Styles } from "@because/forge";
const TopBar = define("FileTopBar", {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 16,
});
const BackLink = define("FileBackLink", {
base: "a",
});
const Title = define("FileTitle", {
base: "h3",
margin: 0,
});
const Actions = define("FileActions", {
display: "flex",
alignItems: "center",
gap: 8,
});
const ActionButton = define("ActionButton", {
base: "a",
padding: "6px 14px",
borderRadius: 4,
fontSize: 14,
textDecoration: "none",
cursor: "pointer",
background: "var(--pico-primary-background)",
color: "var(--pico-primary-inverse)",
states: {
hover: { background: "var(--pico-primary-hover-background)" },
},
variants: {
danger: {
true: {
background: "var(--pico-del-color)",
states: {
hover: { background: "var(--pico-del-color)", opacity: 0.85 },
},
},
},
},
});
const CodeBlock = define("CodeBlock", {
base: "pre",
width: "100%",
minHeight: 200,
fontFamily: "monospace",
fontSize: 14,
padding: 16,
overflow: "auto",
border: "1px solid var(--pico-muted-border-color)",
borderRadius: 4,
whiteSpace: "pre-wrap",
wordWrap: "break-word",
});
const MarkdownContent = define("MarkdownContent", {
base: "div",
width: "100%",
minHeight: 200,
padding: 16,
overflow: "auto",
border: "1px solid var(--pico-muted-border-color)",
borderRadius: 4,
lineHeight: 1.6,
});
export default ({ req }: any) => {
const path = req.param("path") || "";
return (
<section>
<Styles />
<TopBar>
<Title id="file-title">{path}</Title>
<Actions>
<BackLink id="back-link" href="/">
&larr; Back
</BackLink>
<ActionButton href={`/edit/${path.split("/").map(encodeURIComponent).join("/")}`}>Edit</ActionButton>
<ActionButton as="button" id="delete-btn" danger>Delete</ActionButton>
</Actions>
</TopBar>
<CodeBlock id="file-content">Loading...</CodeBlock>
<MarkdownContent id="markdown-content" style="display:none" />
<script
type="module"
dangerouslySetInnerHTML={{
__html: `import "/client/file.ts"`,
}}
/>
</section>
);
};

23
src/pages/_layout.tsx Normal file
View File

@ -0,0 +1,23 @@
import { type FC } from 'hono/jsx'
const Layout: FC = ({ children, title, props }) =>
<html lang="en">
<head>
<title>{title ?? 'hype'}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
{props.reset && <link href="/css/reset.css" rel="stylesheet" />}
{props.pico && <link href="/css/pico.css" rel="stylesheet" />}
<link href="/css/main.css" rel="stylesheet" />
<script src="/client/main.ts" type="module"></script>
</head>
<body>
<main>
{children}
</main>
</body>
</html>
export default Layout

97
src/pages/index.tsx Normal file
View File

@ -0,0 +1,97 @@
import { define, Styles } from "@because/forge";
// --- Forge Components ---
const Toolbar = define("IndexToolbar", {
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 16,
});
const Btn = define("Btn", {
base: "button",
padding: "8px 16px",
borderRadius: 4,
cursor: "pointer",
fontSize: "inherit",
lineHeight: "inherit",
variants: {
right: {
marginLeft: 'auto'
},
intent: {
primary: {
background: "var(--pico-primary-background)",
border: "1px solid var(--pico-primary-background)",
color: "var(--pico-primary-inverse)",
states: {
hover: { background: "var(--pico-primary-hover-background)" },
},
},
outline: {
border: "1px solid var(--pico-muted-border-color)",
color: "var(--pico-color)",
states: {
hover: { borderColor: "var(--pico-primary)" },
},
},
},
},
});
const FormError = define("FormError", {
base: "p",
color: "var(--pico-del-color)",
display: "none",
});
// --- Page ---
export default () => (
<section>
<Styles />
<Toolbar>
<Btn intent="outline" right id="new-file-btn">
+ New File
</Btn>
</Toolbar>
<div id="listing"></div>
<dialog id="new-file-dialog">
<article>
<header>
<button aria-label="Close" rel="prev" id="close-new-dialog"></button>
<strong>New File</strong>
</header>
<form id="new-file-form">
<label>
Filename
<input
type="text"
id="new-file-name"
placeholder="example.txt"
required
autofocus
/>
</label>
<label>
Content
<textarea
id="new-file-content"
rows={6}
placeholder="(optional)"
></textarea>
</label>
<FormError id="new-file-error" />
<Btn type="submit">Create</Btn>
</form>
</article>
</dialog>
</section>
);

88
src/server/fs.ts Normal file
View File

@ -0,0 +1,88 @@
import { resolve, normalize, join, dirname } from "node:path";
import { readdir, stat, readFile, writeFile, mkdir, unlink } from "node:fs/promises";
import { homedir } from "node:os";
function expandHome(p: string): string {
return p.startsWith("~") ? join(homedir(), p.slice(1)) : p;
}
const DATA_DIR = resolve(expandHome(process.env.DATA_DIR || "./data"));
await mkdir(DATA_DIR, { recursive: true });
function safePath(path: string): string {
const normalized = normalize(path);
const full = resolve(DATA_DIR, normalized);
if (!full.startsWith(DATA_DIR + "/") && full !== DATA_DIR) {
throw new Error(`Path escapes root: ${path}`);
}
return full;
}
export async function listFiles(path: string = ".") {
const dir = safePath(path);
const entries = await readdir(dir, { withFileTypes: true });
const results = await Promise.all(
entries.map(async (entry) => {
const base: { name: string; type: "file" | "directory" } = {
name: entry.name,
type: entry.isDirectory() ? "directory" : "file",
};
if (entry.isFile()) {
const info = await stat(join(dir, entry.name));
return { ...base, size: info.size, modified: info.mtime.toISOString() };
}
return base;
})
);
return { entries: results };
}
export async function readFileContent(path: string) {
const full = safePath(path);
const content = await readFile(full, "utf-8");
const info = await stat(full);
return {
path,
content,
size: info.size,
modified: info.mtime.toISOString(),
};
}
export async function fileExists(path: string): Promise<boolean> {
try {
const full = safePath(path);
const info = await stat(full);
return info.isFile();
} catch {
return false;
}
}
export async function createFile(path: string, content: string) {
const full = safePath(path);
const exists = await stat(full).then(() => true, () => false);
if (exists) throw new Error(`File already exists: ${path}`);
await mkdir(dirname(full), { recursive: true });
await writeFile(full, content, "utf-8");
const info = await stat(full);
return { path, size: info.size, created: true };
}
export async function editFile(path: string, content: string) {
const full = safePath(path);
const exists = await stat(full).then(() => true, () => false);
if (!exists) throw new Error(`File does not exist: ${path}`);
await writeFile(full, content, "utf-8");
const info = await stat(full);
return { path, size: info.size, modified: info.mtime.toISOString() };
}
export async function deleteFile(path: string) {
const full = safePath(path);
const info = await stat(full).catch(() => null);
if (!info) throw new Error(`File does not exist: ${path}`);
if (info.isDirectory()) throw new Error(`Cannot delete a directory: ${path}`);
await unlink(full);
return { path, deleted: true };
}

129
src/server/index.ts Normal file
View File

@ -0,0 +1,129 @@
import { Hype } from "@because/hype";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { registerTools } from "./tools.js";
import { listFiles, readFileContent, createFile, editFile, deleteFile, fileExists } from "./fs.js";
import { cors } from "hono/cors";
import { basicAuth } from "hono/basic-auth";
import FilePage from "../pages/_file.js";
import EditPage from "../pages/_edit.js";
import Layout from "../pages/_layout.js";
const app = new Hype({ pico: true, ok: true });
// Override Hype's onError to handle Hono's HTTPException (e.g. from basicAuth)
app.onError((err, c) => {
if ("getResponse" in err) {
return (err as any).getResponse();
}
const isDev = process.env.NODE_ENV !== "production";
return c.html(
`<!DOCTYPE html><html><body><h1>Error: ${err.message}</h1>${isDev ? `<pre>${err.stack}</pre>` : "<p>An error occurred</p>"}</body></html>`,
500
);
});
app.use("/mcp", cors({
origin: "*",
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type", "mcp-session-id", "Last-Event-ID", "mcp-protocol-version"],
exposeHeaders: ["mcp-session-id", "mcp-protocol-version"],
}));
// --- Basic auth for the web UI (skip /mcp) ---
if (process.env.AUTH_USER && process.env.AUTH_PASS) {
app.use("*", async (c, next) => {
if (c.req.path === "/mcp" || c.req.path === "/ok") return next();
const auth = basicAuth({
username: process.env.AUTH_USER!,
password: process.env.AUTH_PASS!,
});
return auth(c, next);
});
}
// --- REST API for the web UI ---
app.get("/api/list", async (c) => {
try {
const path = c.req.query("path") || ".";
return c.json(await listFiles(path));
} catch (e: any) {
return c.json({ error: e.message }, 400);
}
});
app.get("/api/read", async (c) => {
const path = c.req.query("path");
if (!path) return c.json({ error: "path required" }, 400);
try {
return c.json(await readFileContent(path));
} catch (e: any) {
return c.json({ error: e.message }, 400);
}
});
app.post("/api/create", async (c) => {
try {
const { path, content } = await c.req.json();
if (!path) return c.json({ error: "path required" }, 400);
return c.json(await createFile(path, content ?? ""));
} catch (e: any) {
return c.json({ error: e.message }, 400);
}
});
app.post("/api/edit", async (c) => {
try {
const { path, content } = await c.req.json();
if (!path) return c.json({ error: "path required" }, 400);
if (content === undefined) return c.json({ error: "content required" }, 400);
return c.json(await editFile(path, content));
} catch (e: any) {
return c.json({ error: e.message }, 400);
}
});
app.delete("/api/files/:path{.*}", async (c) => {
try {
const path = c.req.param("path");
if (!path) return c.json({ error: "path required" }, 400);
return c.json(await deleteFile(path));
} catch (e: any) {
return c.json({ error: e.message }, 400);
}
});
// --- MCP transport (stateless — fresh transport per request) ---
function createMcpServer(): McpServer {
const server = new McpServer({ name: "mcp.txt", version: "1.0.0" });
registerTools(server);
return server;
}
app.all("/mcp", async (c) => {
const transport = new WebStandardStreamableHTTPServerTransport();
const server = createMcpServer();
await server.connect(transport);
return transport.handleRequest(c.req.raw);
});
// --- Clean URL routes for file view/edit ---
function renderPage(c: any, Page: any) {
const innerHTML = Page({ c, req: c.req });
const withLayout = Layout({ props: app.props, children: innerHTML });
return c.html(withLayout);
}
app.get("/edit/:path{.+}", (c: any) => renderPage(c, EditPage));
app.get("/:path{.+}", async (c: any, next: any) => {
const filePath = c.req.param("path");
if (!(await fileExists(filePath))) return next();
return renderPage(c, FilePage);
});
export default app.defaults;

47
src/server/tools.ts Normal file
View File

@ -0,0 +1,47 @@
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { listFiles, readFileContent, createFile, editFile } from "./fs.js";
export function registerTools(server: McpServer) {
server.registerTool("list_files", {
description: "List files and directories in a given path",
inputSchema: {
path: z.string().default(".").describe("Directory to list, relative to root"),
},
}, async ({ path }) => {
const result = await listFiles(path);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
});
server.registerTool("read_file", {
description: "Read the contents of a text file",
inputSchema: {
path: z.string().describe("File path relative to root"),
},
}, async ({ path }) => {
const result = await readFileContent(path);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
});
server.registerTool("create_file", {
description: "Create a new file. Fails if the file already exists.",
inputSchema: {
path: z.string().describe("File path relative to root"),
content: z.string().describe("File contents"),
},
}, async ({ path, content }) => {
const result = await createFile(path, content);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
});
server.registerTool("edit_file", {
description: "Replace the entire contents of an existing file. Fails if the file doesn't exist.",
inputSchema: {
path: z.string().describe("File path relative to root"),
content: z.string().describe("New file contents"),
},
}, async ({ path, content }) => {
const result = await editFile(path, content);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
});
}

33
tsconfig.json Normal file
View File

@ -0,0 +1,33 @@
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"baseUrl": ".",
"paths": {
"$*": ["src/server/*"],
"#*": ["src/client/*"],
"@*": ["src/shared/*"]
}
}
}