@remcp/remcp 0.1.2 → 0.1.4
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 +54 -107
- package/package.json +12 -52
- package/src/agent.mjs +19 -24
- package/src/cli.mjs +42 -19
- package/src/runtime.mjs +15 -0
- package/src/version.mjs +0 -2
- package/THIRD_PARTY_NOTICES.md +0 -7
- package/assets/remcp-icon.png +0 -0
- package/mcp.json +0 -9
- package/plugin.json +0 -37
- package/public/android-chrome-192x192.png +0 -0
- package/public/android-chrome-512x512.png +0 -0
- package/public/app.js +0 -95
- package/public/apple-touch-icon.png +0 -0
- package/public/assets/art/hero-relay.webp +0 -0
- package/public/assets/art/relay-detail.webp +0 -0
- package/public/assets/icon-120.png +0 -0
- package/public/assets/icon-16.png +0 -0
- package/public/assets/icon-180.png +0 -0
- package/public/assets/icon-192.png +0 -0
- package/public/assets/icon-256.png +0 -0
- package/public/assets/icon-32.png +0 -0
- package/public/assets/icon-48.png +0 -0
- package/public/assets/icon-512.png +0 -0
- package/public/assets/og-remcp.png +0 -0
- package/public/authorize.html +0 -70
- package/public/copy.js +0 -49
- package/public/docs.html +0 -94
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon-48x48.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/index.html +0 -154
- package/public/oauth-app.js +0 -54
- package/public/privacy.html +0 -60
- package/public/remcp-darkmode-16x16.png +0 -0
- package/public/remcp-darkmode-192x192.png +0 -0
- package/public/remcp-darkmode-32x32.png +0 -0
- package/public/remcp-darkmode-48x48.png +0 -0
- package/public/remcp-darkmode-512x512.png +0 -0
- package/public/security.html +0 -57
- package/public/site.webmanifest +0 -23
- package/public/style.css +0 -259
- package/public/support.html +0 -59
- package/public/terms.html +0 -60
- package/skills/remcp-operator/SKILL.md +0 -36
- package/src/auth.mjs +0 -71
- package/src/config.mjs +0 -51
- package/src/db.mjs +0 -102
- package/src/mcp.mjs +0 -95
- package/src/oauth.mjs +0 -164
- package/src/relay.mjs +0 -97
- package/src/review-sandbox.mjs +0 -96
- package/src/server.mjs +0 -159
- package/src/tool-catalog.json +0 -796
- package/src/util.mjs +0 -24
package/src/review-sandbox.mjs
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync, appendFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
-
import { resolve, relative, dirname, basename } from 'node:path';
|
|
3
|
-
import { sha256 } from './util.mjs';
|
|
4
|
-
|
|
5
|
-
export const REVIEW_SANDBOX_ID = 'review-sandbox';
|
|
6
|
-
|
|
7
|
-
function text(value, isError = false) {
|
|
8
|
-
return { content: [{ type: 'text', text: String(value) }], ...(isError ? { isError: true } : {}) };
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function rootFor(uid) {
|
|
12
|
-
const dataDir = process.env.DATA_DIR || './data';
|
|
13
|
-
const root = resolve(dataDir, 'review-sandboxes', sha256(uid).slice(0, 32), 'workspace');
|
|
14
|
-
mkdirSync(root, { recursive: true });
|
|
15
|
-
const readme = resolve(root, 'README.txt');
|
|
16
|
-
try { statSync(readme); } catch {
|
|
17
|
-
writeFileSync(readme, 'ReMCP Review Sandbox\n\nThis isolated workspace is available for submission testing.\n', { mode: 0o600 });
|
|
18
|
-
}
|
|
19
|
-
return root;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function safePath(uid, input = '/workspace') {
|
|
23
|
-
const root = rootFor(uid);
|
|
24
|
-
let candidate = String(input || '').trim().replace(/\\/g, '/');
|
|
25
|
-
if (candidate === '/workspace' || candidate === 'workspace' || candidate === '/') candidate = '';
|
|
26
|
-
candidate = candidate.replace(/^\/workspace\/?/, '').replace(/^\/+/, '');
|
|
27
|
-
const target = resolve(root, candidate);
|
|
28
|
-
const rel = relative(root, target);
|
|
29
|
-
if (rel.startsWith('..') || rel === '..') throw new Error('Path must stay inside /workspace');
|
|
30
|
-
return { root, target, display: `/workspace${rel ? `/${rel.replaceAll('\\', '/')}` : ''}` };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function reviewSandboxDevice() {
|
|
34
|
-
return { id: REVIEW_SANDBOX_ID, name: 'ReMCP Review Sandbox', hostname: 'isolated-review-workspace', platform: 'sandbox', arch: 'virtual', online: true, connectedAt: null, sandbox: true };
|
|
35
|
-
}
|
|
36
|
-
function listDirectory(uid, args) {
|
|
37
|
-
const { target, display } = safePath(uid, args.path);
|
|
38
|
-
const entries = readdirSync(target, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
39
|
-
const lines = entries.map(entry => `${entry.isDirectory() ? '[DIR]' : '[FILE]'} ${entry.name}`);
|
|
40
|
-
return text(`${display}\n${lines.join('\n') || '(empty)'}`);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function readFile(uid, args) {
|
|
44
|
-
if (args.isUrl) return text('The review sandbox does not fetch URLs. Pair a real device to use URL reads.', true);
|
|
45
|
-
const { target, display } = safePath(uid, args.path);
|
|
46
|
-
const body = readFileSync(target, 'utf8');
|
|
47
|
-
const lines = body.split(/\r?\n/);
|
|
48
|
-
const offset = Math.max(0, Number(args.offset || 0));
|
|
49
|
-
const length = Math.max(1, Math.min(1000, Number(args.length || 1000)));
|
|
50
|
-
return text(`${display}\n${lines.slice(offset, offset + length).join('\n')}`);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function writeFile(uid, args) {
|
|
54
|
-
const { target, display } = safePath(uid, args.path);
|
|
55
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
56
|
-
if (args.mode === 'append') appendFileSync(target, String(args.content), { mode: 0o600 });
|
|
57
|
-
else writeFileSync(target, String(args.content), { mode: 0o600 });
|
|
58
|
-
return text(`Wrote ${Buffer.byteLength(String(args.content))} bytes to ${display}.`);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function createDirectory(uid, args) {
|
|
62
|
-
const { target, display } = safePath(uid, args.path);
|
|
63
|
-
mkdirSync(target, { recursive: true });
|
|
64
|
-
return text(`Directory ready: ${display}`);
|
|
65
|
-
}
|
|
66
|
-
function fileInfo(uid, args) {
|
|
67
|
-
const { target, display } = safePath(uid, args.path);
|
|
68
|
-
const info = statSync(target);
|
|
69
|
-
return text(JSON.stringify({ path: display, type: info.isDirectory() ? 'directory' : 'file', size: info.size, modifiedAt: info.mtime.toISOString() }, null, 2));
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function startProcess(uid, args) {
|
|
73
|
-
const root = rootFor(uid);
|
|
74
|
-
const command = String(args.command || '').trim();
|
|
75
|
-
if (command === 'pwd') return text('/workspace');
|
|
76
|
-
if (/^ls(?:\s+-[alh]+)?$/.test(command)) return listDirectory(uid, { path: '/workspace' });
|
|
77
|
-
const cat = command.match(/^cat\s+(.+)$/);
|
|
78
|
-
if (cat) return readFile(uid, { path: cat[1].replace(/^['"]|['"]$/g, '') });
|
|
79
|
-
const echo = command.match(/^echo(?:\s+)(.*)$/s);
|
|
80
|
-
if (echo) return text(echo[1].replace(/^['"]|['"]$/g, ''));
|
|
81
|
-
return text(`Review Sandbox only permits pwd, ls, cat, and echo. Pair a real device to run other commands. Workspace: ${root}`, true);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export function reviewSandboxCall(uid, name, args = {}) {
|
|
85
|
-
try {
|
|
86
|
-
if (name === 'list_directory') return listDirectory(uid, args);
|
|
87
|
-
if (name === 'read_file') return readFile(uid, args);
|
|
88
|
-
if (name === 'write_file') return writeFile(uid, args);
|
|
89
|
-
if (name === 'create_directory') return createDirectory(uid, args);
|
|
90
|
-
if (name === 'get_file_info') return fileInfo(uid, args);
|
|
91
|
-
if (name === 'start_process') return startProcess(uid, args);
|
|
92
|
-
return text(`${name} is not enabled in the isolated Review Sandbox. Pair a real device to use this tool.`, true);
|
|
93
|
-
} catch (error) {
|
|
94
|
-
return text(error instanceof Error ? error.message : String(error), true);
|
|
95
|
-
}
|
|
96
|
-
}
|
package/src/server.mjs
DELETED
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import http from 'node:http';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import express from 'express';
|
|
5
|
-
import helmet from 'helmet';
|
|
6
|
-
import { rateLimit } from 'express-rate-limit';
|
|
7
|
-
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
8
|
-
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
-
import { config } from './config.mjs';
|
|
10
|
-
import { createDevice, createPairCode, consumePairCode, revokeDevice } from './db.mjs';
|
|
11
|
-
import { requireFirebase, requireMcpAuth } from './auth.mjs';
|
|
12
|
-
import { oauthRouter } from './oauth.mjs';
|
|
13
|
-
import { createRelay } from './relay.mjs';
|
|
14
|
-
import { createMcpServer } from './mcp.mjs';
|
|
15
|
-
import { now, randomId, randomToken, sha256 } from './util.mjs';
|
|
16
|
-
import { VERSION } from './version.mjs';
|
|
17
|
-
|
|
18
|
-
const app = express();
|
|
19
|
-
app.set('trust proxy', 1);
|
|
20
|
-
app.disable('x-powered-by');
|
|
21
|
-
const firebaseFrame = `https://${config.firebase.authDomain}`;
|
|
22
|
-
app.use(helmet({
|
|
23
|
-
contentSecurityPolicy: {
|
|
24
|
-
directives: {
|
|
25
|
-
defaultSrc: ["'self'"],
|
|
26
|
-
scriptSrc: ["'self'", 'https://www.gstatic.com', 'https://apis.google.com', 'https://www.google.com'],
|
|
27
|
-
connectSrc: ["'self'", 'https://identitytoolkit.googleapis.com', 'https://securetoken.googleapis.com', 'https://www.googleapis.com', 'https://apis.google.com', 'https://accounts.google.com'],
|
|
28
|
-
imgSrc: ["'self'", 'data:'],
|
|
29
|
-
styleSrc: ["'self'"],
|
|
30
|
-
fontSrc: ["'none'"],
|
|
31
|
-
frameSrc: [firebaseFrame, 'https://accounts.google.com', 'https://apis.google.com'],
|
|
32
|
-
objectSrc: ["'none'"],
|
|
33
|
-
baseUri: ["'none'"],
|
|
34
|
-
formAction: ["'self'", 'https://accounts.google.com'],
|
|
35
|
-
frameAncestors: ["'none'"],
|
|
36
|
-
},
|
|
37
|
-
},
|
|
38
|
-
crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' },
|
|
39
|
-
strictTransportSecurity: config.publicUrl.startsWith('https://') ? { maxAge: 31536000, includeSubDomains: false } : false,
|
|
40
|
-
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
|
41
|
-
}));
|
|
42
|
-
app.use((req, res, next) => {
|
|
43
|
-
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
44
|
-
const dynamicPath = req.path === '/health' || req.path === '/mcp' || req.path.startsWith('/api/') || req.path.startsWith('/oauth/') || req.path.startsWith('/.well-known/');
|
|
45
|
-
if (dynamicPath) {
|
|
46
|
-
res.setHeader('Cache-Control', 'no-store, max-age=0');
|
|
47
|
-
res.setHeader('CDN-Cache-Control', 'no-store');
|
|
48
|
-
res.setHeader('Cloudflare-CDN-Cache-Control', 'no-store');
|
|
49
|
-
}
|
|
50
|
-
next();
|
|
51
|
-
});
|
|
52
|
-
app.use(express.json({ limit: '256kb' }));
|
|
53
|
-
app.use(express.urlencoded({ extended: false, limit: '256kb', parameterLimit: 50 }));
|
|
54
|
-
const pairClaimLimiter = rateLimit({ windowMs: 60_000, limit: 20, standardHeaders: 'draft-8', legacyHeaders: false });
|
|
55
|
-
|
|
56
|
-
const httpServer = http.createServer(app);
|
|
57
|
-
const relay = createRelay(httpServer);
|
|
58
|
-
const sessions = new Map();
|
|
59
|
-
const publicDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../public');
|
|
60
|
-
|
|
61
|
-
app.get('/health', (_req, res) => res.json({ ok: true, service: 'remcp', version: VERSION, connectedDevices: relay.onlineCount() }));
|
|
62
|
-
app.get(config.domainVerificationPath, (_req, res) => {
|
|
63
|
-
if (!config.domainVerificationToken) return res.status(404).type('text/plain').send('Not configured');
|
|
64
|
-
res.type('text/plain').send(config.domainVerificationToken);
|
|
65
|
-
});
|
|
66
|
-
app.get('/api/config', (_req, res) => res.json({ publicUrl: config.publicUrl, mcpUrl: `${config.publicUrl}/mcp`, firebase: config.firebase }));
|
|
67
|
-
app.get('/.well-known/security.txt', (_req, res) => res.type('text/plain').send(`Contact: ${config.publicUrl}/support\nPolicy: ${config.publicUrl}/security\nExpires: 2027-09-16T00:00:00Z\nCanonical: ${config.publicUrl}/.well-known/security.txt\n`));
|
|
68
|
-
|
|
69
|
-
app.post('/api/pair/create', requireFirebase, (req, res) => {
|
|
70
|
-
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
71
|
-
let code = '';
|
|
72
|
-
const bytes = Buffer.from(randomToken(12));
|
|
73
|
-
for (let i = 0; i < 8; i++) code += alphabet[bytes[i] % alphabet.length];
|
|
74
|
-
const expiresAt = now() + config.pairTtlSeconds * 1000;
|
|
75
|
-
createPairCode(req.user.uid, sha256(code), expiresAt);
|
|
76
|
-
res.json({ code, expiresAt, command: `npx --yes ${config.installSpec} connect --server ${config.publicUrl} --code ${code} --install` });
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
app.post('/api/pair/claim', pairClaimLimiter, (req, res) => {
|
|
80
|
-
const code = String(req.body?.code || '').replace(/\s+/g, '').toUpperCase();
|
|
81
|
-
const pair = consumePairCode(sha256(code));
|
|
82
|
-
if (!pair) return res.status(400).json({ error: 'invalid_or_expired_pairing_code' });
|
|
83
|
-
const deviceToken = randomToken(32);
|
|
84
|
-
const device = createDevice({
|
|
85
|
-
id: randomId(), uid: pair.uid,
|
|
86
|
-
name: String(req.body?.name || req.body?.hostname || 'My computer').slice(0, 100),
|
|
87
|
-
tokenHash: sha256(deviceToken), hostname: String(req.body?.hostname || '').slice(0, 180),
|
|
88
|
-
platform: String(req.body?.platform || '').slice(0, 32), arch: String(req.body?.arch || '').slice(0, 32),
|
|
89
|
-
});
|
|
90
|
-
res.json({ deviceId: device.id, deviceToken, agentUrl: config.publicUrl.replace(/^http/, 'ws') + '/agent' });
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
app.get('/api/me', requireFirebase, (req, res) => res.json({ user: req.user, devices: relay.devicesFor(req.user.uid) }));
|
|
94
|
-
app.delete('/api/devices/:id', requireFirebase, (req, res) => {
|
|
95
|
-
const result = revokeDevice(req.user.uid, String(req.params.id));
|
|
96
|
-
if (!result.changes) return res.status(404).json({ error: 'device_not_found' });
|
|
97
|
-
res.status(204).end();
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
app.use(oauthRouter);
|
|
101
|
-
app.all('/mcp', requireMcpAuth, async (req, res) => {
|
|
102
|
-
try {
|
|
103
|
-
const sessionId = req.headers['mcp-session-id'];
|
|
104
|
-
let entry = typeof sessionId === 'string' ? sessions.get(sessionId) : undefined;
|
|
105
|
-
if (entry && entry.uid !== req.mcpAuth.uid) return res.status(403).json({ error: 'session_owner_mismatch' });
|
|
106
|
-
if (!entry) {
|
|
107
|
-
if (sessionId || !isInitializeRequest(req.body)) return res.status(400).json({ error: 'invalid_or_missing_mcp_session' });
|
|
108
|
-
const mcpServer = createMcpServer(req.mcpAuth.uid, relay);
|
|
109
|
-
let transport;
|
|
110
|
-
transport = new StreamableHTTPServerTransport({
|
|
111
|
-
sessionIdGenerator: randomId,
|
|
112
|
-
onsessioninitialized: id => sessions.set(id, { transport, mcpServer, uid: req.mcpAuth.uid }),
|
|
113
|
-
onsessionclosed: id => sessions.delete(id),
|
|
114
|
-
});
|
|
115
|
-
transport.onclose = () => {
|
|
116
|
-
const id = transport.sessionId;
|
|
117
|
-
if (id) sessions.delete(id);
|
|
118
|
-
void mcpServer.close();
|
|
119
|
-
};
|
|
120
|
-
await mcpServer.connect(transport);
|
|
121
|
-
entry = { transport, mcpServer, uid: req.mcpAuth.uid };
|
|
122
|
-
}
|
|
123
|
-
await entry.transport.handleRequest(req, res, req.body);
|
|
124
|
-
} catch (error) {
|
|
125
|
-
console.error('MCP request failed', error);
|
|
126
|
-
if (!res.headersSent) res.status(500).json({ error: 'mcp_request_failed' });
|
|
127
|
-
}
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
const noStoreExtensions = new Set(['.html', '.css', '.js', '.webmanifest']);
|
|
131
|
-
app.use(express.static(publicDir, {
|
|
132
|
-
extensions: ['html'],
|
|
133
|
-
maxAge: 0,
|
|
134
|
-
dotfiles: 'deny',
|
|
135
|
-
setHeaders: (res, filePath) => {
|
|
136
|
-
const extension = path.extname(filePath).toLowerCase();
|
|
137
|
-
if (noStoreExtensions.has(extension)) {
|
|
138
|
-
// The public site changes independently from the API. Never let a CDN
|
|
139
|
-
// combine HTML from one deploy with CSS/JS from another deploy.
|
|
140
|
-
res.setHeader('Cache-Control', 'no-store, max-age=0');
|
|
141
|
-
res.setHeader('CDN-Cache-Control', 'no-store');
|
|
142
|
-
res.setHeader('Cloudflare-CDN-Cache-Control', 'no-store');
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
146
|
-
},
|
|
147
|
-
}));
|
|
148
|
-
app.get('*path', (req, res) => {
|
|
149
|
-
if (!req.accepts('html')) return res.status(404).json({ error: 'not_found' });
|
|
150
|
-
res.status(404).sendFile(path.join(publicDir, 'index.html'));
|
|
151
|
-
});
|
|
152
|
-
app.use((error, _req, res, _next) => {
|
|
153
|
-
console.error('Unhandled request error', error instanceof Error ? error.message : String(error));
|
|
154
|
-
if (!res.headersSent) res.status(500).json({ error: 'internal_server_error' });
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
httpServer.listen(config.port, '0.0.0.0', () => {
|
|
158
|
-
console.log(`ReMCP ${config.publicUrl} listening on 0.0.0.0:${config.port}`);
|
|
159
|
-
});
|