@3sln/trove 0.0.2
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 +1227 -0
- package/package.json +75 -0
- package/packages/core/src/collections/index.js +249 -0
- package/packages/core/src/errors.js +186 -0
- package/packages/core/src/identity/discovery.js +210 -0
- package/packages/core/src/identity/index.js +188 -0
- package/packages/core/src/identity/jwt.js +199 -0
- package/packages/core/src/index.js +104 -0
- package/packages/core/src/indexers/contribution.js +115 -0
- package/packages/core/src/indexers/registry.js +162 -0
- package/packages/core/src/indexing.js +340 -0
- package/packages/core/src/issues.js +150 -0
- package/packages/core/src/kv.js +0 -0
- package/packages/core/src/links.js +141 -0
- package/packages/core/src/metadata/cursor.js +73 -0
- package/packages/core/src/metadata/interface.js +244 -0
- package/packages/core/src/metadata/memory.js +270 -0
- package/packages/core/src/metadata/sqlite.js +412 -0
- package/packages/core/src/notifications/index.js +139 -0
- package/packages/core/src/notifications/webpush.js +217 -0
- package/packages/core/src/plugins/contributions.js +177 -0
- package/packages/core/src/plugins/identity.js +98 -0
- package/packages/core/src/plugins/index.js +225 -0
- package/packages/core/src/plugins/indexers.js +142 -0
- package/packages/core/src/plugins/installStore.js +134 -0
- package/packages/core/src/plugins/package.js +102 -0
- package/packages/core/src/plugins/packageStore.js +61 -0
- package/packages/core/src/plugins/runtime.js +101 -0
- package/packages/core/src/plugins/sql.js +52 -0
- package/packages/core/src/retry.js +74 -0
- package/packages/core/src/scan.js +302 -0
- package/packages/core/src/search/embeddings.js +128 -0
- package/packages/core/src/search/index.js +200 -0
- package/packages/core/src/search/keywordStore.js +107 -0
- package/packages/core/src/search/sqliteStores.js +455 -0
- package/packages/core/src/search/tagMatch.js +59 -0
- package/packages/core/src/search/transformer.js +195 -0
- package/packages/core/src/search/vectorStore.js +274 -0
- package/packages/core/src/search/vectorize.js +249 -0
- package/packages/core/src/sidecar/document.js +213 -0
- package/packages/core/src/sidecar/index.js +174 -0
- package/packages/core/src/sidecar/manager.js +239 -0
- package/packages/core/src/sidecar/store.js +46 -0
- package/packages/core/src/signedUrls.js +170 -0
- package/packages/core/src/sqlite-d1.js +162 -0
- package/packages/core/src/sqlite-driver.js +42 -0
- package/packages/core/src/sqlite.js +162 -0
- package/packages/core/src/storage/filesystem.js +283 -0
- package/packages/core/src/storage/interface.js +222 -0
- package/packages/core/src/storage/memory.js +113 -0
- package/packages/core/src/storage/prefixed.js +75 -0
- package/packages/core/src/storage/s3.js +316 -0
- package/packages/core/src/storage/s3sigv4.js +185 -0
- package/packages/core/src/tasks.js +228 -0
- package/packages/core/src/uploads.js +386 -0
- package/packages/core/src/util.js +125 -0
- package/packages/core/src/vfs.js +666 -0
- package/packages/plugin-sdk/src/browser.js +316 -0
- package/packages/plugin-sdk/src/index.js +32 -0
- package/packages/plugin-sdk/src/protocol.js +59 -0
- package/packages/plugin-sdk/src/rpc.js +95 -0
- package/packages/server/src/adapters/bun.js +78 -0
- package/packages/server/src/adapters/node.js +115 -0
- package/packages/server/src/adapters/staticAssets.js +123 -0
- package/packages/server/src/adapters/webDist.js +70 -0
- package/packages/server/src/adapters/worker-tasks.js +206 -0
- package/packages/server/src/adapters/worker.js +159 -0
- package/packages/server/src/cachePolicy.js +34 -0
- package/packages/server/src/engine/README.md +88 -0
- package/packages/server/src/engine/actions/scanCollection.js +114 -0
- package/packages/server/src/engine/index.js +95 -0
- package/packages/server/src/engine/lazy.js +25 -0
- package/packages/server/src/engine/providers/access.js +363 -0
- package/packages/server/src/engine/providers/core.js +405 -0
- package/packages/server/src/engine/providers/scan.js +67 -0
- package/packages/server/src/index.js +698 -0
- package/packages/server/src/manifest.js +98 -0
- package/packages/server/src/mcp/auth.js +40 -0
- package/packages/server/src/mcp/index.js +213 -0
- package/packages/server/src/mcp/protocol.js +181 -0
- package/packages/server/src/mcp/tools.js +351 -0
- package/packages/server/src/router.js +229 -0
- package/packages/server/src/routes.js +1066 -0
- package/packages/server/src/scope.js +43 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
- package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
- package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
- package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
- package/packages/web/dist/icon.svg +11 -0
- package/packages/web/dist/index.html +16 -0
- package/packages/web/dist/sql-wasm.wasm +0 -0
- package/packages/web/dist/sw.js +186 -0
- package/packages/web/src/bl/actions.js +410 -0
- package/packages/web/src/bl/activity.js +306 -0
- package/packages/web/src/bl/commands.js +274 -0
- package/packages/web/src/bl/fileType.js +49 -0
- package/packages/web/src/bl/index.js +70 -0
- package/packages/web/src/bl/links.js +54 -0
- package/packages/web/src/bl/offline.js +268 -0
- package/packages/web/src/bl/openers.js +71 -0
- package/packages/web/src/bl/pluginInstall.js +59 -0
- package/packages/web/src/bl/services.js +143 -0
- package/packages/web/src/bl/social.js +234 -0
- package/packages/web/src/bl/tagQuery.js +44 -0
- package/packages/web/src/main.js +10 -0
- package/packages/web/src/platform/api.js +529 -0
- package/packages/web/src/platform/commands.js +89 -0
- package/packages/web/src/platform/context.js +77 -0
- package/packages/web/src/platform/contributions.js +156 -0
- package/packages/web/src/platform/index.js +150 -0
- package/packages/web/src/platform/keybindings.js +199 -0
- package/packages/web/src/platform/mediaUrls.js +137 -0
- package/packages/web/src/platform/navigation.js +131 -0
- package/packages/web/src/platform/notifications.js +50 -0
- package/packages/web/src/platform/overlay.js +81 -0
- package/packages/web/src/platform/pluginClientDb.js +132 -0
- package/packages/web/src/platform/pluginDock.js +141 -0
- package/packages/web/src/platform/pluginFrames.js +194 -0
- package/packages/web/src/platform/pluginHost.js +648 -0
- package/packages/web/src/platform/pluginMedia.js +62 -0
- package/packages/web/src/platform/pluginModules.js +90 -0
- package/packages/web/src/platform/pluginNet.js +71 -0
- package/packages/web/src/platform/pluginPackage.js +247 -0
- package/packages/web/src/platform/pluginRpc.js +377 -0
- package/packages/web/src/platform/pluginSigning.js +168 -0
- package/packages/web/src/platform/pluginStore.js +67 -0
- package/packages/web/src/platform/settings.js +101 -0
- package/packages/web/src/platform/spatialNav.js +286 -0
- package/packages/web/src/platform/viewport.js +123 -0
- package/packages/web/src/platform/voice.js +133 -0
- package/packages/web/src/platform/voiceSearch.js +155 -0
- package/packages/web/src/platform/whenclause.js +162 -0
- package/packages/web/src/platform/workbench.js +156 -0
- package/packages/web/src/runtime.js +73 -0
- package/packages/web/src/styles.css +1382 -0
- package/packages/web/src/ui/components/activityBar.js +35 -0
- package/packages/web/src/ui/components/activityPanel.js +132 -0
- package/packages/web/src/ui/components/commandPalette.js +154 -0
- package/packages/web/src/ui/components/editorArea.js +75 -0
- package/packages/web/src/ui/components/launcher.js +392 -0
- package/packages/web/src/ui/components/openers/index.js +212 -0
- package/packages/web/src/ui/components/openers/markdown.js +222 -0
- package/packages/web/src/ui/components/overlays.js +255 -0
- package/packages/web/src/ui/components/phoneChrome.js +188 -0
- package/packages/web/src/ui/components/pluginReview.js +151 -0
- package/packages/web/src/ui/components/pluginsView.js +120 -0
- package/packages/web/src/ui/components/settingsView.js +258 -0
- package/packages/web/src/ui/components/social.js +290 -0
- package/packages/web/src/ui/components/statusBar.js +198 -0
- package/packages/web/src/ui/components/views/grid.js +115 -0
- package/packages/web/src/ui/components/views/index.js +155 -0
- package/packages/web/src/ui/components/views/list.js +50 -0
- package/packages/web/src/ui/components/views/parts.js +58 -0
- package/packages/web/src/ui/compositions/workbench.js +125 -0
- package/packages/web/src/ui/format.js +33 -0
- package/packages/web/src/ui/icon.js +81 -0
- package/packages/web/src/ui/media.js +114 -0
- package/packages/web/src/ui/sanitize.js +86 -0
- package/packages/web/src/workbench.js +205 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The web app manifest.
|
|
2
|
+
//
|
|
3
|
+
// There was already a manifest — a static file in packages/web/public, baked at build
|
|
4
|
+
// time and identical in every deployment. That is fine for exactly one drive and wrong
|
|
5
|
+
// for a self-hosted product: an operator running this for a team wants their name on
|
|
6
|
+
// the installed icon, not "Trove". Changing it meant editing a file inside the package
|
|
7
|
+
// and rebuilding, which the shipped-dist model has just made something nobody does.
|
|
8
|
+
//
|
|
9
|
+
// So it is generated per request from configuration, like everything else an operator
|
|
10
|
+
// sets. The static file is gone rather than kept as a fallback: two documents that must
|
|
11
|
+
// agree, one of which is only reachable in development, is how they stop agreeing.
|
|
12
|
+
//
|
|
13
|
+
// No `node:` imports — this is on the runtime-agnostic side and has to load on Workers.
|
|
14
|
+
|
|
15
|
+
export const MANIFEST_PATH = '/manifest.webmanifest';
|
|
16
|
+
|
|
17
|
+
const DISPLAY_MODES = ['standalone', 'fullscreen', 'minimal-ui', 'browser'];
|
|
18
|
+
|
|
19
|
+
const ICON_TYPES = {
|
|
20
|
+
'.svg': 'image/svg+xml', '.png': 'image/png', '.webp': 'image/webp',
|
|
21
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.ico': 'image/x-icon',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Guess from the extension, since we cannot read the file — it may not even be ours. */
|
|
25
|
+
function iconType(src) {
|
|
26
|
+
const dot = src.lastIndexOf('.');
|
|
27
|
+
return dot === -1 ? undefined : ICON_TYPES[src.slice(dot).toLowerCase().split('?')[0]];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read the operator's choices out of the environment.
|
|
32
|
+
*
|
|
33
|
+
* Everything is optional and everything has a default that produces the document the
|
|
34
|
+
* static file used to contain, so a deployment that sets none of this is unchanged.
|
|
35
|
+
*
|
|
36
|
+
* @param {Record<string, string|undefined>} env
|
|
37
|
+
*/
|
|
38
|
+
export function manifestFromEnv(env = {}) {
|
|
39
|
+
const m = {};
|
|
40
|
+
const set = (key, value) => { if (value !== undefined && value !== '') m[key] = value; };
|
|
41
|
+
|
|
42
|
+
set('name', env.TROVE_APP_NAME);
|
|
43
|
+
set('shortName', env.TROVE_APP_SHORT_NAME);
|
|
44
|
+
set('description', env.TROVE_APP_DESCRIPTION);
|
|
45
|
+
set('themeColor', env.TROVE_APP_THEME_COLOR);
|
|
46
|
+
set('backgroundColor', env.TROVE_APP_BACKGROUND_COLOR);
|
|
47
|
+
set('display', env.TROVE_APP_DISPLAY);
|
|
48
|
+
set('startUrl', env.TROVE_APP_START_URL);
|
|
49
|
+
set('icon', env.TROVE_APP_ICON);
|
|
50
|
+
set('iconSizes', env.TROVE_APP_ICON_SIZES);
|
|
51
|
+
set('iconType', env.TROVE_APP_ICON_TYPE);
|
|
52
|
+
|
|
53
|
+
// The escape hatch. A drive that wants a maskable icon, six raster sizes and a
|
|
54
|
+
// monochrome badge is not going to be expressible in flat variables, and inventing a
|
|
55
|
+
// mini-language for it would be worse than accepting the array the spec already
|
|
56
|
+
// defines. Malformed JSON is ignored rather than fatal: a typo here should not stop a
|
|
57
|
+
// drive from booting.
|
|
58
|
+
if (env.TROVE_APP_ICONS) {
|
|
59
|
+
try {
|
|
60
|
+
const icons = JSON.parse(env.TROVE_APP_ICONS);
|
|
61
|
+
if (Array.isArray(icons) && icons.length) m.icons = icons;
|
|
62
|
+
} catch { /* keep the single-icon path */ }
|
|
63
|
+
}
|
|
64
|
+
return m;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build the manifest document.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} [m] the `manifest` block of the server config
|
|
71
|
+
* @returns {object} a W3C web app manifest
|
|
72
|
+
*/
|
|
73
|
+
export function webManifest(m = {}) {
|
|
74
|
+
const name = m.name || 'Trove';
|
|
75
|
+
const theme = m.themeColor || '#181a1f';
|
|
76
|
+
const src = m.icon || '/icon.svg';
|
|
77
|
+
|
|
78
|
+
const icons = m.icons || [{
|
|
79
|
+
src,
|
|
80
|
+
sizes: m.iconSizes || 'any',
|
|
81
|
+
// An SVG is resolution-independent and `any` is honest about it; a raster file at
|
|
82
|
+
// `any` is a lie the browser will believe and then scale badly, so a host pointing
|
|
83
|
+
// at a PNG is expected to say what size it is.
|
|
84
|
+
type: m.iconType || iconType(src),
|
|
85
|
+
purpose: 'any',
|
|
86
|
+
}];
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
name,
|
|
90
|
+
short_name: m.shortName || name,
|
|
91
|
+
description: m.description || 'Your self-hosted drive with semantic search, media players, and plugins.',
|
|
92
|
+
start_url: m.startUrl || '/',
|
|
93
|
+
display: DISPLAY_MODES.includes(m.display) ? m.display : 'standalone',
|
|
94
|
+
background_color: m.backgroundColor || theme,
|
|
95
|
+
theme_color: theme,
|
|
96
|
+
icons: icons.map((i) => Object.fromEntries(Object.entries(i).filter(([, v]) => v !== undefined))),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// MCP's slice of the drive's auth discovery.
|
|
2
|
+
//
|
|
3
|
+
// The authorization server lives in core (identity/discovery.js) because it is a
|
|
4
|
+
// property of the DEPLOYMENT, not of this endpoint: an unauthenticated call to
|
|
5
|
+
// /api/items and an unauthenticated call to /mcp have the same answer, and a client
|
|
6
|
+
// pointed at either should be sent to the same place. Two settings for one fact is how
|
|
7
|
+
// they end up disagreeing.
|
|
8
|
+
//
|
|
9
|
+
// What IS specific to MCP is the resource identifier — the canonical URI naming this
|
|
10
|
+
// endpoint, which an agent puts in the RFC 8707 `resource` parameter and which the
|
|
11
|
+
// token's audience should match. That is about which resource, not about which
|
|
12
|
+
// authorization server, so it stays here.
|
|
13
|
+
|
|
14
|
+
import { publicOrigin } from '@3sln/trove/core';
|
|
15
|
+
|
|
16
|
+
/** Read MCP settings out of the process environment. */
|
|
17
|
+
export function mcpConfigFromEnv(env = {}) {
|
|
18
|
+
const cfg = {};
|
|
19
|
+
if (env.TROVE_MCP != null) cfg.enabled = !/^(0|off|false|no)$/i.test(String(env.TROVE_MCP));
|
|
20
|
+
if (env.TROVE_MCP_PATH) cfg.path = env.TROVE_MCP_PATH;
|
|
21
|
+
if (env.TROVE_MCP_RESOURCE) cfg.resource = String(env.TROVE_MCP_RESOURCE).replace(/\/+$/, '');
|
|
22
|
+
// Whether, not where. The drive's posture is the default — see authRequired — and this
|
|
23
|
+
// exists for the deployment that wants an open web app and a locked-down agent
|
|
24
|
+
// endpoint, or the reverse.
|
|
25
|
+
if (env.TROVE_MCP_REQUIRE_AUTH != null) cfg.requireAuth = !/^(0|off|false|no)$/i.test(String(env.TROVE_MCP_REQUIRE_AUTH));
|
|
26
|
+
return cfg;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The canonical URI of this MCP endpoint.
|
|
31
|
+
*
|
|
32
|
+
* Derived from the request when not configured, so a self-hoster doesn't have to tell
|
|
33
|
+
* the server its own address. Configured explicitly when the drive sits behind a proxy
|
|
34
|
+
* that rewrites the Host in a way the forwarded headers don't capture, where guessing
|
|
35
|
+
* would produce an identifier no token will ever match.
|
|
36
|
+
*/
|
|
37
|
+
export function mcpResourceUri(req, cfg = {}, serverConfig = {}) {
|
|
38
|
+
if (cfg.resource) return String(cfg.resource).replace(/\/+$/, '');
|
|
39
|
+
return `${publicOrigin(req, serverConfig)}${cfg.path || '/mcp'}`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// The MCP endpoint: one URL, JSON-RPC over HTTP, guarded by the drive's own identity.
|
|
2
|
+
//
|
|
3
|
+
// This is the "Streamable HTTP" transport. Trove implements the half of it that matters
|
|
4
|
+
// for a server with no server-initiated messages: POST carries a request and gets a
|
|
5
|
+
// single JSON response back. The GET-for-SSE half exists in the spec so a server can
|
|
6
|
+
// push notifications, and Trove has none to push — so it says 405 rather than holding a
|
|
7
|
+
// stream open that will never carry anything. A client that needs streaming falls back
|
|
8
|
+
// to plain POST, which is what it would end up using anyway.
|
|
9
|
+
//
|
|
10
|
+
// Authentication reuses the drive's IdentityProvider verbatim, and its authorization
|
|
11
|
+
// server comes from the drive's config rather than from anything MCP-specific. That is
|
|
12
|
+
// the point of the exercise: an agent presents the same JWT the browser does, from the
|
|
13
|
+
// same place, subject to the same verification and the same collection permissions. It
|
|
14
|
+
// is not a second access-control system to keep in sync with the first.
|
|
15
|
+
|
|
16
|
+
import { TroveError, challengeHeaders, protectedResourceMetadata } from '@3sln/trove/core';
|
|
17
|
+
import { McpServer, rpcError, JSONRPC_ERRORS } from './protocol.js';
|
|
18
|
+
import { registerTroveTools } from './tools.js';
|
|
19
|
+
import { mcpConfigFromEnv, mcpResourceUri } from './auth.js';
|
|
20
|
+
import { crossSiteRefusal } from '../router.js';
|
|
21
|
+
import { leaseScope } from '../scope.js';
|
|
22
|
+
|
|
23
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
/** The CORS origin to echo, or null for "no cross-origin access" — same rule as the API. */
|
|
26
|
+
function allowedOrigin(configured, reqOrigin) {
|
|
27
|
+
if (!configured) return null;
|
|
28
|
+
if (configured === '*') return '*';
|
|
29
|
+
const allowed = String(configured).split(',').map((s) => s.trim()).filter(Boolean);
|
|
30
|
+
return reqOrigin && allowed.includes(reqOrigin) ? reqOrigin : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export { McpServer, mcpConfigFromEnv, mcpResourceUri };
|
|
34
|
+
|
|
35
|
+
export function createMcpServer({ name = 'trove', version = '0.0.1' } = {}) {
|
|
36
|
+
return registerTroveTools(new McpServer({ name, version }));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function jsonResponse(body, status = 200, headers = {}) {
|
|
40
|
+
return new Response(body == null ? null : JSON.stringify(body), {
|
|
41
|
+
status,
|
|
42
|
+
headers: {
|
|
43
|
+
...(body == null ? {} : { 'content-type': 'application/json' }),
|
|
44
|
+
'x-content-type-options': 'nosniff',
|
|
45
|
+
...headers,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Does this deployment require a token for MCP?
|
|
52
|
+
*
|
|
53
|
+
* Default: follow the drive. A drive running open (the zero-config case, one shared
|
|
54
|
+
* anonymous user) exposes MCP the same way, because demanding a token from an agent for
|
|
55
|
+
* a drive that demands none from a browser protects nothing. A drive with a real
|
|
56
|
+
* identity provider requires one. An operator can force either.
|
|
57
|
+
*/
|
|
58
|
+
function authRequired(cfg, identity) {
|
|
59
|
+
if (cfg.requireAuth != null) return !!cfg.requireAuth;
|
|
60
|
+
return !identity?.constructor?.name?.startsWith('Anonymous');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the MCP request handler.
|
|
65
|
+
*
|
|
66
|
+
* Returns null when MCP is switched off, so the caller can skip the routes entirely
|
|
67
|
+
* rather than serving an endpoint that 404s in a way indistinguishable from a typo.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} deps
|
|
70
|
+
* @param {object} deps.auth the drive's resolved auth discovery (see resolveAuthDiscovery)
|
|
71
|
+
*/
|
|
72
|
+
export function createMcpHandler({ vfs, collections, container, identity, config = {}, auth = {}, version } = {}) {
|
|
73
|
+
const cfg = { path: '/mcp', enabled: true, ...mcpConfigFromEnv(config.env || {}), ...(config.mcp || {}) };
|
|
74
|
+
if (cfg.enabled === false) return null;
|
|
75
|
+
const server = createMcpServer({ version });
|
|
76
|
+
const path = cfg.path || '/mcp';
|
|
77
|
+
|
|
78
|
+
/** The 401 an agent uses to discover where to sign in. */
|
|
79
|
+
const unauthorized = (req, description) => jsonResponse(
|
|
80
|
+
// A JSON-RPC error body as well as the header, because a client that reads the body
|
|
81
|
+
// before the status still gets told what happened.
|
|
82
|
+
rpcError(null, JSONRPC_ERRORS.INVALID_REQUEST, description || 'Authentication required'),
|
|
83
|
+
401,
|
|
84
|
+
challengeHeaders(mcpResourceUri(req, cfg, config), auth, { description }),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
async function handle(req, url) {
|
|
88
|
+
// --- discovery: RFC 9728 for THIS endpoint --------------------------------
|
|
89
|
+
// The drive's own document is served by the caller at the bare well-known path;
|
|
90
|
+
// this is the /mcp-suffixed one, which names the MCP endpoint as the resource.
|
|
91
|
+
// Always unauthenticated — a document whose whole job is to say how to authenticate
|
|
92
|
+
// cannot itself require a token.
|
|
93
|
+
if (url.pathname === `/.well-known/oauth-protected-resource${path}`) {
|
|
94
|
+
return jsonResponse(protectedResourceMetadata(mcpResourceUri(req, cfg, config), auth), 200, {
|
|
95
|
+
'cache-control': 'public, max-age=3600',
|
|
96
|
+
'access-control-allow-origin': '*',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (url.pathname !== path) return null;
|
|
101
|
+
|
|
102
|
+
if (req.method === 'OPTIONS') {
|
|
103
|
+
// Echoing the request's own Origin approves EVERY site. On the zero-config
|
|
104
|
+
// deployment MCP needs no token, so that let any page the user happened to be
|
|
105
|
+
// visiting call write_file and delete_file on their drive — the reply is
|
|
106
|
+
// unreadable to the attacker, but the deletions still happen. Agents are not
|
|
107
|
+
// browsers and do not need CORS at all, so this follows the same
|
|
108
|
+
// TROVE_CORS_ORIGIN allowlist the JSON API does, and stays off by default.
|
|
109
|
+
const origin = allowedOrigin(config?.corsOrigin, req.headers.get('origin'));
|
|
110
|
+
if (!origin) return new Response(null, { status: 204 });
|
|
111
|
+
return new Response(null, {
|
|
112
|
+
status: 204,
|
|
113
|
+
headers: {
|
|
114
|
+
'access-control-allow-origin': origin,
|
|
115
|
+
...(origin === '*' ? {} : { vary: 'Origin' }),
|
|
116
|
+
'access-control-allow-methods': 'POST, GET, DELETE, OPTIONS',
|
|
117
|
+
'access-control-allow-headers': 'content-type, authorization, mcp-protocol-version, mcp-session-id',
|
|
118
|
+
'access-control-expose-headers': 'www-authenticate, mcp-session-id',
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// No server-initiated messages, so no stream to open. Said plainly.
|
|
124
|
+
if (req.method === 'GET') {
|
|
125
|
+
return jsonResponse({ error: 'This server sends no unsolicited messages; POST JSON-RPC requests instead.' }, 405,
|
|
126
|
+
{ allow: 'POST, OPTIONS' });
|
|
127
|
+
}
|
|
128
|
+
// Sessions are not used — every request stands alone — so there is nothing to end.
|
|
129
|
+
if (req.method === 'DELETE') return new Response(null, { status: 204 });
|
|
130
|
+
if (req.method !== 'POST') return jsonResponse({ error: 'Method not allowed' }, 405, { allow: 'POST, OPTIONS' });
|
|
131
|
+
|
|
132
|
+
// The OPTIONS allowlist above only governs requests a browser preflights. A
|
|
133
|
+
// cross-site POST with `content-type: text/plain` is a CORS simple request — no
|
|
134
|
+
// preflight, so nothing consulted that allowlist, and `delete_file` ran. Agents are
|
|
135
|
+
// not browsers and send none of these headers, so this costs them nothing.
|
|
136
|
+
const refused = crossSiteRefusal(req, config);
|
|
137
|
+
if (refused) return refused;
|
|
138
|
+
|
|
139
|
+
// --- identity -------------------------------------------------------------
|
|
140
|
+
let principal = null;
|
|
141
|
+
const needsAuth = authRequired(cfg, identity);
|
|
142
|
+
try {
|
|
143
|
+
principal = await identity.authenticate(req);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
// A bad token is a 401 WITH the challenge, not a bare rejection — an agent whose
|
|
146
|
+
// token expired needs to be told where to get another one, which is the same
|
|
147
|
+
// answer as for an agent that never had one.
|
|
148
|
+
const e = err instanceof TroveError ? err : null;
|
|
149
|
+
if (e && e.code === 'transient') {
|
|
150
|
+
return jsonResponse(rpcError(null, JSONRPC_ERRORS.INTERNAL, e.message), 503);
|
|
151
|
+
}
|
|
152
|
+
return unauthorized(req, err?.message || 'The token was not accepted.');
|
|
153
|
+
}
|
|
154
|
+
if (needsAuth && (!principal || principal.anonymous)) {
|
|
155
|
+
return unauthorized(req, 'This drive requires a bearer token.');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- the message ----------------------------------------------------------
|
|
159
|
+
const declared = Number(req.headers.get('content-length') || 0);
|
|
160
|
+
if (declared > MAX_BODY_BYTES) {
|
|
161
|
+
return jsonResponse(rpcError(null, JSONRPC_ERRORS.INVALID_REQUEST, 'Request too large'), 413);
|
|
162
|
+
}
|
|
163
|
+
let text;
|
|
164
|
+
try {
|
|
165
|
+
text = await req.text();
|
|
166
|
+
} catch {
|
|
167
|
+
return jsonResponse(rpcError(null, JSONRPC_ERRORS.PARSE, 'Could not read the request body'), 400);
|
|
168
|
+
}
|
|
169
|
+
if (text.length > MAX_BODY_BYTES) {
|
|
170
|
+
return jsonResponse(rpcError(null, JSONRPC_ERRORS.INVALID_REQUEST, 'Request too large'), 413);
|
|
171
|
+
}
|
|
172
|
+
let msg;
|
|
173
|
+
try {
|
|
174
|
+
msg = JSON.parse(text);
|
|
175
|
+
} catch {
|
|
176
|
+
return jsonResponse(rpcError(null, JSONRPC_ERRORS.PARSE, 'Body is not valid JSON'), 400);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The same authorization the browser gets, from the same place: a tool asks for a
|
|
180
|
+
// node or collection HANDLE and operates through it, so there is no MCP-shaped path
|
|
181
|
+
// around the collection ACL and no unrestricted `vfs` sitting in a tool body.
|
|
182
|
+
const scope = leaseScope(container, principal);
|
|
183
|
+
const ctx = { vfs, collections, principal, config, access: scope.access };
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
// A batch is an array. Notifications inside it contribute nothing to the reply,
|
|
187
|
+
// and a batch of only notifications gets 202 with no body at all.
|
|
188
|
+
if (Array.isArray(msg)) {
|
|
189
|
+
if (!msg.length) return jsonResponse(rpcError(null, JSONRPC_ERRORS.INVALID_REQUEST, 'Empty batch'), 400);
|
|
190
|
+
const replies = (await Promise.all(msg.map((m) => server.dispatch(m, ctx)))).filter(Boolean);
|
|
191
|
+
return replies.length ? jsonResponse(replies) : new Response(null, { status: 202 });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const reply = await server.dispatch(msg, ctx);
|
|
195
|
+
// A notification gets 202 and NO body. Returning a JSON-RPC envelope here is the
|
|
196
|
+
// classic way to make a conformant client decide the server is broken.
|
|
197
|
+
if (!reply) return new Response(null, { status: 202 });
|
|
198
|
+
return jsonResponse(reply);
|
|
199
|
+
} finally {
|
|
200
|
+
await scope.release();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
handle,
|
|
206
|
+
server,
|
|
207
|
+
path,
|
|
208
|
+
config: cfg,
|
|
209
|
+
/** What to paste into an agent, for a given request's public origin. */
|
|
210
|
+
endpoint: (req) => mcpResourceUri(req, cfg, config),
|
|
211
|
+
requiresAuth: () => authRequired(cfg, identity),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// The MCP wire protocol: JSON-RPC 2.0 over a single HTTP endpoint.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately small. Trove is an MCP *server* exposing a handful of tools, not a
|
|
4
|
+
// framework, and the surface an agent actually exercises is `initialize`, `tools/list`,
|
|
5
|
+
// `tools/call`, and the resource pair. Everything else is answered honestly with
|
|
6
|
+
// "method not found" rather than half-implemented.
|
|
7
|
+
//
|
|
8
|
+
// Two shapes matter and are easy to get wrong:
|
|
9
|
+
//
|
|
10
|
+
// - A NOTIFICATION (no `id`) gets no response body at all — 202 with nothing. Sending
|
|
11
|
+
// a JSON-RPC result for `notifications/initialized` makes conformant clients treat
|
|
12
|
+
// the connection as broken.
|
|
13
|
+
// - A tool that FAILS is not a JSON-RPC error. It returns a normal result carrying
|
|
14
|
+
// `isError: true`, because the model is supposed to see what went wrong and try
|
|
15
|
+
// something else. JSON-RPC errors are reserved for the protocol itself being wrong —
|
|
16
|
+
// an unknown method, malformed params — which the model cannot do anything about.
|
|
17
|
+
|
|
18
|
+
// The newest final revision at the time of writing. Older clients get their own version
|
|
19
|
+
// echoed back when we can speak it, which is how MCP negotiation works: the server picks
|
|
20
|
+
// from what the client asked for rather than forcing an upgrade.
|
|
21
|
+
export const LATEST_PROTOCOL = '2025-11-25';
|
|
22
|
+
export const SUPPORTED_PROTOCOLS = ['2025-11-25', '2025-06-18', '2025-03-26'];
|
|
23
|
+
|
|
24
|
+
export const JSONRPC_ERRORS = {
|
|
25
|
+
PARSE: -32700,
|
|
26
|
+
INVALID_REQUEST: -32600,
|
|
27
|
+
METHOD_NOT_FOUND: -32601,
|
|
28
|
+
INVALID_PARAMS: -32602,
|
|
29
|
+
INTERNAL: -32603,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function rpcResult(id, result) {
|
|
33
|
+
return { jsonrpc: '2.0', id, result };
|
|
34
|
+
}
|
|
35
|
+
export function rpcError(id, code, message, data) {
|
|
36
|
+
return { jsonrpc: '2.0', id: id ?? null, error: { code, message, ...(data ? { data } : {}) } };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A tool result the model can read. `isError` is how a failure reaches the model. */
|
|
40
|
+
export function toolText(text, { isError = false, structured } = {}) {
|
|
41
|
+
return {
|
|
42
|
+
content: [{ type: 'text', text: String(text) }],
|
|
43
|
+
...(structured ? { structuredContent: structured } : {}),
|
|
44
|
+
...(isError ? { isError: true } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class McpServer {
|
|
49
|
+
/**
|
|
50
|
+
* @param {object} opts
|
|
51
|
+
* @param {string} opts.name
|
|
52
|
+
* @param {string} opts.version
|
|
53
|
+
* @param {string} [opts.instructions] shown to the model once, describing the server
|
|
54
|
+
*/
|
|
55
|
+
constructor({ name, version, instructions } = {}) {
|
|
56
|
+
this.name = name || 'mcp';
|
|
57
|
+
this.version = version || '0.0.0';
|
|
58
|
+
this.instructions = instructions || '';
|
|
59
|
+
this.tools = new Map();
|
|
60
|
+
this.resourceProviders = null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {object} tool
|
|
65
|
+
* @param {string} tool.name
|
|
66
|
+
* @param {string} tool.description written for a MODEL to read, not a person
|
|
67
|
+
* @param {object} tool.inputSchema JSON Schema for the arguments
|
|
68
|
+
* @param {(args: object, ctx: object) => Promise<any>} tool.run
|
|
69
|
+
* @param {boolean} [tool.readOnly] declared as a hint, and enforced by the caller
|
|
70
|
+
*/
|
|
71
|
+
tool(tool) {
|
|
72
|
+
this.tools.set(tool.name, tool);
|
|
73
|
+
return this;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @param {{list: Function, read: Function}} providers */
|
|
77
|
+
resources(providers) {
|
|
78
|
+
this.resourceProviders = providers;
|
|
79
|
+
return this;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Handle one JSON-RPC message. Returns null for notifications — the caller must send
|
|
84
|
+
* no body at all, not `null` serialized.
|
|
85
|
+
*/
|
|
86
|
+
async dispatch(msg, ctx = {}) {
|
|
87
|
+
if (!msg || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
|
|
88
|
+
return rpcError(msg?.id, JSONRPC_ERRORS.INVALID_REQUEST, 'Not a JSON-RPC 2.0 request');
|
|
89
|
+
}
|
|
90
|
+
const isNotification = msg.id === undefined || msg.id === null;
|
|
91
|
+
const params = msg.params || {};
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
switch (msg.method) {
|
|
95
|
+
case 'initialize':
|
|
96
|
+
return rpcResult(msg.id, this.#initialize(params));
|
|
97
|
+
|
|
98
|
+
// The client telling us it is ready. Nothing to say back.
|
|
99
|
+
case 'notifications/initialized':
|
|
100
|
+
case 'notifications/cancelled':
|
|
101
|
+
return null;
|
|
102
|
+
|
|
103
|
+
case 'ping':
|
|
104
|
+
return rpcResult(msg.id, {});
|
|
105
|
+
|
|
106
|
+
case 'tools/list':
|
|
107
|
+
return rpcResult(msg.id, {
|
|
108
|
+
tools: [...this.tools.values()]
|
|
109
|
+
.filter((t) => !t.available || t.available(ctx))
|
|
110
|
+
.map((t) => ({
|
|
111
|
+
name: t.name,
|
|
112
|
+
title: t.title || undefined,
|
|
113
|
+
description: t.description,
|
|
114
|
+
inputSchema: t.inputSchema,
|
|
115
|
+
...(t.readOnly ? { annotations: { readOnlyHint: true } } : {}),
|
|
116
|
+
})),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
case 'tools/call':
|
|
120
|
+
return rpcResult(msg.id, await this.#call(params, ctx));
|
|
121
|
+
|
|
122
|
+
case 'resources/list':
|
|
123
|
+
if (!this.resourceProviders) break;
|
|
124
|
+
return rpcResult(msg.id, await this.resourceProviders.list(params, ctx));
|
|
125
|
+
|
|
126
|
+
case 'resources/read':
|
|
127
|
+
if (!this.resourceProviders) break;
|
|
128
|
+
return rpcResult(msg.id, await this.resourceProviders.read(params, ctx));
|
|
129
|
+
|
|
130
|
+
// Declared as unsupported in `initialize`, so a conformant client won't ask.
|
|
131
|
+
// Answered anyway, because one that does should get an empty list rather than
|
|
132
|
+
// an error it treats as the connection failing.
|
|
133
|
+
case 'prompts/list':
|
|
134
|
+
return rpcResult(msg.id, { prompts: [] });
|
|
135
|
+
case 'resources/templates/list':
|
|
136
|
+
return rpcResult(msg.id, { resourceTemplates: [] });
|
|
137
|
+
|
|
138
|
+
default:
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
if (isNotification) return null; // an unknown notification is not worth an error
|
|
142
|
+
return rpcError(msg.id, JSONRPC_ERRORS.METHOD_NOT_FOUND, `Unknown method: ${msg.method}`);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (isNotification) return null;
|
|
145
|
+
// Something in the dispatch itself broke — not the tool, which handles its own
|
|
146
|
+
// failures below.
|
|
147
|
+
return rpcError(msg.id, JSONRPC_ERRORS.INTERNAL, err?.message || 'Internal error');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#initialize(params) {
|
|
152
|
+
const asked = params.protocolVersion;
|
|
153
|
+
return {
|
|
154
|
+
protocolVersion: SUPPORTED_PROTOCOLS.includes(asked) ? asked : LATEST_PROTOCOL,
|
|
155
|
+
capabilities: {
|
|
156
|
+
tools: { listChanged: false },
|
|
157
|
+
...(this.resourceProviders ? { resources: { subscribe: false, listChanged: false } } : {}),
|
|
158
|
+
},
|
|
159
|
+
serverInfo: { name: this.name, version: this.version },
|
|
160
|
+
...(this.instructions ? { instructions: this.instructions } : {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async #call(params, ctx) {
|
|
165
|
+
const tool = this.tools.get(params.name);
|
|
166
|
+
// An unknown tool IS a tool-level failure, not a protocol one: models hallucinate
|
|
167
|
+
// tool names, and the useful response is one the model can read and correct from.
|
|
168
|
+
if (!tool) return toolText(`No such tool: ${params.name}`, { isError: true });
|
|
169
|
+
if (tool.available && !tool.available(ctx)) {
|
|
170
|
+
return toolText(`The tool "${params.name}" is not available to you.`, { isError: true });
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const out = await tool.run(params.arguments || {}, ctx);
|
|
174
|
+
return out?.content ? out : toolText(typeof out === 'string' ? out : JSON.stringify(out, null, 2));
|
|
175
|
+
} catch (err) {
|
|
176
|
+
// Permission denied, file missing, drive full — all of it goes to the model as
|
|
177
|
+
// readable text so it can adjust, rather than to the transport as a fault.
|
|
178
|
+
return toolText(err?.message || 'The tool failed', { isError: true });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|