@remcp/remcp 0.1.3 → 0.2.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.
Files changed (56) hide show
  1. package/README.md +41 -113
  2. package/package.json +10 -24
  3. package/src/agent.mjs +215 -36
  4. package/src/cli.mjs +217 -43
  5. package/src/runtime.mjs +15 -0
  6. package/src/version.mjs +1 -3
  7. package/THIRD_PARTY_NOTICES.md +0 -7
  8. package/assets/remcp-icon.png +0 -0
  9. package/mcp.json +0 -9
  10. package/plugin.json +0 -37
  11. package/public/android-chrome-192x192.png +0 -0
  12. package/public/android-chrome-512x512.png +0 -0
  13. package/public/app.js +0 -96
  14. package/public/apple-touch-icon.png +0 -0
  15. package/public/assets/art/hero-relay.webp +0 -0
  16. package/public/assets/art/relay-detail.webp +0 -0
  17. package/public/assets/icon-120.png +0 -0
  18. package/public/assets/icon-16.png +0 -0
  19. package/public/assets/icon-180.png +0 -0
  20. package/public/assets/icon-192.png +0 -0
  21. package/public/assets/icon-256.png +0 -0
  22. package/public/assets/icon-32.png +0 -0
  23. package/public/assets/icon-48.png +0 -0
  24. package/public/assets/icon-512.png +0 -0
  25. package/public/assets/og-remcp.png +0 -0
  26. package/public/authorize.html +0 -59
  27. package/public/copy.js +0 -40
  28. package/public/docs.html +0 -93
  29. package/public/favicon-16x16.png +0 -0
  30. package/public/favicon-32x32.png +0 -0
  31. package/public/favicon-48x48.png +0 -0
  32. package/public/favicon.ico +0 -0
  33. package/public/index.html +0 -134
  34. package/public/oauth-app.js +0 -63
  35. package/public/privacy.html +0 -80
  36. package/public/remcp-darkmode-16x16.png +0 -0
  37. package/public/remcp-darkmode-192x192.png +0 -0
  38. package/public/remcp-darkmode-32x32.png +0 -0
  39. package/public/remcp-darkmode-48x48.png +0 -0
  40. package/public/remcp-darkmode-512x512.png +0 -0
  41. package/public/security.html +0 -70
  42. package/public/site.webmanifest +0 -23
  43. package/public/style.css +0 -261
  44. package/public/support.html +0 -75
  45. package/public/terms.html +0 -77
  46. package/skills/remcp-operator/SKILL.md +0 -36
  47. package/src/auth.mjs +0 -71
  48. package/src/config.mjs +0 -51
  49. package/src/db.mjs +0 -102
  50. package/src/mcp.mjs +0 -95
  51. package/src/oauth.mjs +0 -164
  52. package/src/relay.mjs +0 -97
  53. package/src/review-sandbox.mjs +0 -96
  54. package/src/server.mjs +0 -159
  55. package/src/tool-catalog.json +0 -796
  56. package/src/util.mjs +0 -24
package/src/db.mjs DELETED
@@ -1,102 +0,0 @@
1
- import { mkdirSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { DatabaseSync } from 'node:sqlite';
4
- import { config } from './config.mjs';
5
- import { now } from './util.mjs';
6
-
7
- mkdirSync(config.dataDir, { recursive: true });
8
- export const db = new DatabaseSync(join(config.dataDir, 'remcp.sqlite'));
9
- db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;`);
10
- db.exec(`
11
- CREATE TABLE IF NOT EXISTS users (
12
- uid TEXT PRIMARY KEY, email TEXT, name TEXT, email_verified INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, last_login_at INTEGER NOT NULL
13
- );
14
- CREATE TABLE IF NOT EXISTS pair_codes (
15
- code_hash TEXT PRIMARY KEY, uid TEXT NOT NULL REFERENCES users(uid) ON DELETE CASCADE,
16
- expires_at INTEGER NOT NULL, used_at INTEGER
17
- );
18
- CREATE TABLE IF NOT EXISTS devices (
19
- id TEXT PRIMARY KEY, uid TEXT NOT NULL REFERENCES users(uid) ON DELETE CASCADE,
20
- name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, hostname TEXT, platform TEXT, arch TEXT,
21
- created_at INTEGER NOT NULL, last_seen_at INTEGER, revoked_at INTEGER
22
- );
23
- CREATE INDEX IF NOT EXISTS devices_uid_idx ON devices(uid);
24
- CREATE TABLE IF NOT EXISTS oauth_clients (
25
- client_id TEXT PRIMARY KEY, client_name TEXT, redirect_uris TEXT NOT NULL, created_at INTEGER NOT NULL
26
- );
27
- CREATE TABLE IF NOT EXISTS oauth_codes (
28
- code_hash TEXT PRIMARY KEY, client_id TEXT NOT NULL, uid TEXT NOT NULL,
29
- redirect_uri TEXT NOT NULL, code_challenge TEXT NOT NULL, scope TEXT NOT NULL,
30
- expires_at INTEGER NOT NULL, used_at INTEGER
31
- );
32
- CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
33
- token_hash TEXT PRIMARY KEY, client_id TEXT NOT NULL, uid TEXT NOT NULL,
34
- scope TEXT NOT NULL, expires_at INTEGER NOT NULL, revoked_at INTEGER
35
- );
36
- `);
37
- try { db.exec('ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0'); } catch {}
38
-
39
- export const getUser = uid => db.prepare('SELECT * FROM users WHERE uid=?').get(uid);
40
-
41
- export function upsertUser({ uid, email = '', name = '', emailVerified = false }) {
42
- const t = now();
43
- db.prepare(`INSERT INTO users(uid,email,name,email_verified,created_at,last_login_at) VALUES(?,?,?,?,?,?)
44
- ON CONFLICT(uid) DO UPDATE SET email=excluded.email,name=excluded.name,email_verified=excluded.email_verified,last_login_at=excluded.last_login_at`)
45
- .run(uid, email, name, emailVerified ? 1 : 0, t, t);
46
- return db.prepare('SELECT * FROM users WHERE uid=?').get(uid);
47
- }
48
-
49
- export function createPairCode(uid, codeHash, expiresAt) {
50
- db.prepare('DELETE FROM pair_codes WHERE expires_at < ? OR used_at IS NOT NULL').run(now());
51
- db.prepare('INSERT INTO pair_codes(code_hash,uid,expires_at) VALUES(?,?,?)').run(codeHash, uid, expiresAt);
52
- }
53
-
54
- export function consumePairCode(codeHash) {
55
- db.exec('BEGIN IMMEDIATE');
56
- try {
57
- const row = db.prepare('SELECT * FROM pair_codes WHERE code_hash=? AND used_at IS NULL AND expires_at>?').get(codeHash, now());
58
- if (row) db.prepare('UPDATE pair_codes SET used_at=? WHERE code_hash=?').run(now(), codeHash);
59
- db.exec('COMMIT');
60
- return row;
61
- } catch (error) { db.exec('ROLLBACK'); throw error; }
62
- }
63
-
64
- export function createDevice(device) {
65
- db.prepare(`INSERT INTO devices(id,uid,name,token_hash,hostname,platform,arch,created_at,last_seen_at)
66
- VALUES(?,?,?,?,?,?,?,?,?)`).run(device.id, device.uid, device.name, device.tokenHash, device.hostname || '', device.platform || '', device.arch || '', now(), now());
67
- return getDevice(device.id);
68
- }
69
- export const getDevice = id => db.prepare('SELECT * FROM devices WHERE id=?').get(id);
70
- export const getDeviceByTokenHash = hash => db.prepare('SELECT * FROM devices WHERE token_hash=? AND revoked_at IS NULL').get(hash);
71
- export const listDevices = uid => db.prepare('SELECT id,name,hostname,platform,arch,created_at,last_seen_at,revoked_at FROM devices WHERE uid=? ORDER BY created_at DESC').all(uid);
72
- export function touchDevice(id, meta = {}) {
73
- db.prepare(`UPDATE devices SET name=COALESCE(?,name),hostname=COALESCE(?,hostname),platform=COALESCE(?,platform),arch=COALESCE(?,arch),last_seen_at=? WHERE id=?`)
74
- .run(meta.name ?? null, meta.hostname ?? null, meta.platform ?? null, meta.arch ?? null, now(), id);
75
- }
76
- export function revokeDevice(uid, id) { return db.prepare('UPDATE devices SET revoked_at=? WHERE uid=? AND id=?').run(now(), uid, id); }
77
-
78
- export function saveOauthClient({ clientId, clientName = 'MCP client', redirectUris }) {
79
- db.prepare('INSERT OR REPLACE INTO oauth_clients(client_id,client_name,redirect_uris,created_at) VALUES(?,?,?,?)')
80
- .run(clientId, clientName, JSON.stringify(redirectUris), now());
81
- }
82
- export const getOauthClient = clientId => db.prepare('SELECT * FROM oauth_clients WHERE client_id=?').get(clientId);
83
-
84
- export function createOauthCode(row) {
85
- db.prepare(`INSERT INTO oauth_codes(code_hash,client_id,uid,redirect_uri,code_challenge,scope,expires_at) VALUES(?,?,?,?,?,?,?)`)
86
- .run(row.codeHash, row.clientId, row.uid, row.redirectUri, row.codeChallenge, row.scope, row.expiresAt);
87
- }
88
- export function consumeOauthCode(codeHash) {
89
- db.exec('BEGIN IMMEDIATE');
90
- try {
91
- const row = db.prepare('SELECT * FROM oauth_codes WHERE code_hash=? AND used_at IS NULL AND expires_at>?').get(codeHash, now());
92
- if (row) db.prepare('UPDATE oauth_codes SET used_at=? WHERE code_hash=?').run(now(), codeHash);
93
- db.exec('COMMIT');
94
- return row;
95
- } catch (error) { db.exec('ROLLBACK'); throw error; }
96
- }
97
- export function saveRefreshToken(row) {
98
- db.prepare('INSERT INTO oauth_refresh_tokens(token_hash,client_id,uid,scope,expires_at) VALUES(?,?,?,?,?)')
99
- .run(row.tokenHash, row.clientId, row.uid, row.scope, row.expiresAt);
100
- }
101
- export const getRefreshToken = hash => db.prepare('SELECT * FROM oauth_refresh_tokens WHERE token_hash=? AND revoked_at IS NULL AND expires_at>?').get(hash, now());
102
- export const revokeRefreshToken = hash => db.prepare('UPDATE oauth_refresh_tokens SET revoked_at=? WHERE token_hash=?').run(now(), hash);
package/src/mcp.mjs DELETED
@@ -1,95 +0,0 @@
1
- import { readFileSync } from 'node:fs';
2
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
- import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
- import { VERSION } from './version.mjs';
5
-
6
- const rawCatalog = JSON.parse(readFileSync(new URL('./tool-catalog.json', import.meta.url), 'utf8'));
7
- const destructive = new Set(['set_config_value','write_file','write_pdf','move_file','edit_block','start_process','interact_with_process','force_terminate','kill_process']);
8
- const openWorld = new Set(['start_process','interact_with_process']);
9
- const descriptions = {
10
- get_config: 'Read the local device runtime configuration for the selected paired device.',
11
- set_config_value: 'Change one supported local device runtime configuration value on the selected paired device.',
12
- read_file: 'Read a local file or a user-supplied URL from the selected paired device.',
13
- read_multiple_files: 'Read multiple local files from the selected paired device in one call.',
14
- write_file: 'Create, replace, or append to a file on the selected paired device.',
15
- write_pdf: 'Create a PDF or apply supported page edits to a PDF on the selected paired device.',
16
- create_directory: 'Create a directory, including missing parent directories, on the selected paired device.',
17
- list_directory: 'List files and directories at a path on the selected paired device.',
18
- move_file: 'Move or rename a file or directory on the selected paired device.',
19
- start_search: 'Start a local filename or content search session on the selected paired device.',
20
- get_more_search_results: 'Read additional results from an existing local search session on the selected paired device.',
21
- stop_search: 'Stop an active local search session on the selected paired device.',
22
- list_searches: 'List active local search sessions on the selected paired device.',
23
- get_file_info: 'Read file or directory metadata from the selected paired device.',
24
- edit_block: 'Apply an exact targeted text or supported document edit on the selected paired device.',
25
- start_process: 'Start a command or interactive process on the selected paired device. The command may change local or external state.',
26
- read_process_output: 'Read buffered output from a running process on the selected paired device.',
27
- interact_with_process: 'Send input to a running process on the selected paired device. Input may change local or external state.',
28
- force_terminate: 'Force-stop a terminal session on the selected paired device.',
29
- list_sessions: 'List active terminal sessions on the selected paired device.',
30
- list_processes: 'List running operating-system processes on the selected paired device.',
31
- kill_process: 'Terminate a process by PID on the selected paired device.',
32
- get_usage_stats: 'Read local device runtime usage statistics from the selected paired device.',
33
- get_recent_tool_calls: 'Read recent local device runtime tool-call history from the selected paired device.',
34
- };
35
-
36
- function publicDescription(tool) {
37
- return String(descriptions[tool.name] || tool.description || '').replace(/\s+/g, ' ').trim();
38
- }
39
-
40
- function publicTool(tool) {
41
- const schema = structuredClone(tool.inputSchema || { type: 'object', properties: {} });
42
- schema.type = 'object';
43
- schema.properties ||= {};
44
- schema.properties.device = {
45
- type: 'string',
46
- description: 'ReMCP device id returned by list_devices. Required for every device operation.',
47
- };
48
- schema.required = [...new Set([...(schema.required || []), 'device'])];
49
- const upstream = tool.annotations || {};
50
- return {
51
- name: tool.name,
52
- title: upstream.title || tool.name,
53
- description: publicDescription(tool),
54
- inputSchema: schema,
55
- annotations: {
56
- title: upstream.title || tool.name,
57
- readOnlyHint: tool.name === 'start_search' ? false : upstream.readOnlyHint === true,
58
- destructiveHint: destructive.has(tool.name),
59
- idempotentHint: upstream.idempotentHint === true,
60
- openWorldHint: openWorld.has(tool.name) || tool.name === 'read_file' || upstream.openWorldHint === true,
61
- },
62
- };
63
- }
64
-
65
- const excludedPublicTools = new Set(['get_usage_stats','get_recent_tool_calls']);
66
- const catalog = rawCatalog.filter(tool => !excludedPublicTools.has(tool.name)).map(publicTool);
67
- const listDevicesTool = {
68
- name: 'list_devices',
69
- title: 'List ReMCP devices',
70
- description: 'List computers paired to the signed-in ReMCP account and show whether each one is online.',
71
- inputSchema: { type: 'object', properties: {}, additionalProperties: false },
72
- annotations: { title: 'List ReMCP devices', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
73
- };
74
-
75
- export function createMcpServer(uid, relay) {
76
- const server = new Server({ name: 'remcp', version: VERSION }, { capabilities: { tools: {} } });
77
-
78
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [listDevicesTool, ...catalog] }));
79
- server.setRequestHandler(CallToolRequestSchema, async request => {
80
- const name = request.params.name;
81
- if (name === 'list_devices') {
82
- return { content: [{ type: 'text', text: JSON.stringify({ devices: relay.devicesFor(uid) }, null, 2) }] };
83
- }
84
- if (!catalog.some(tool => tool.name === name)) throw new Error(`Unknown tool: ${name}`);
85
- const args = { ...(request.params.arguments || {}) };
86
- const device = String(args.device || '');
87
- if (!device) throw new Error('device is required');
88
- delete args.device;
89
- return relay.rpc(uid, device, 'tools/call', { name, arguments: args });
90
- });
91
-
92
- return server;
93
- }
94
-
95
- export const publicToolCatalog = [listDevicesTool, ...catalog];
package/src/oauth.mjs DELETED
@@ -1,164 +0,0 @@
1
- import crypto from 'node:crypto';
2
- import express from 'express';
3
- import { rateLimit } from 'express-rate-limit';
4
- import { config } from './config.mjs';
5
- import { verifyFirebaseToken, signMcpAccessToken, verifyMcpAccessToken } from './auth.mjs';
6
- import { createOauthCode, consumeOauthCode, getOauthClient, getRefreshToken, getUser, revokeRefreshToken, saveOauthClient, saveRefreshToken, upsertUser } from './db.mjs';
7
- import { bearer, normalizeScope, now, randomId, randomToken, sha256, validRedirectUri } from './util.mjs';
8
-
9
- const CODE_TTL_MS = 5 * 60 * 1000;
10
- const router = express.Router();
11
- const oauthLimiter = rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: 'draft-8', legacyHeaders: false });
12
- const tokenLimiter = rateLimit({ windowMs: 60_000, limit: 30, standardHeaders: 'draft-8', legacyHeaders: false });
13
- const endpoint = path => `${config.publicUrl}${path}`;
14
-
15
- function metadata() {
16
- return {
17
- issuer: config.publicUrl,
18
- authorization_endpoint: endpoint('/oauth/authorize'),
19
- token_endpoint: endpoint('/oauth/token'),
20
- registration_endpoint: endpoint('/oauth/register'),
21
- userinfo_endpoint: endpoint('/oauth/userinfo'),
22
- revocation_endpoint: endpoint('/oauth/revoke'),
23
- response_types_supported: ['code'],
24
- grant_types_supported: ['authorization_code', 'refresh_token'],
25
- token_endpoint_auth_methods_supported: ['none'],
26
- code_challenge_methods_supported: ['S256'],
27
- scopes_supported: ['openid', 'email', 'remcp:control', 'offline_access'],
28
- };
29
- }
30
-
31
- function protectedResource() {
32
- return {
33
- resource: endpoint('/mcp'),
34
- authorization_servers: [config.publicUrl],
35
- bearer_methods_supported: ['header'],
36
- scopes_supported: ['openid', 'email', 'remcp:control', 'offline_access'],
37
- resource_documentation: config.publicUrl,
38
- resource_policy_uri: endpoint('/privacy'),
39
- resource_tos_uri: endpoint('/terms'),
40
- };
41
- }
42
-
43
- router.get('/.well-known/oauth-authorization-server', (_req, res) => res.json(metadata()));
44
- router.get('/oauth/.well-known/oauth-authorization-server', (_req, res) => res.json(metadata()));
45
- router.get('/.well-known/oauth-protected-resource', (_req, res) => res.json(protectedResource()));
46
- router.get('/.well-known/oauth-protected-resource/mcp', (_req, res) => res.json(protectedResource()));
47
-
48
- router.post('/oauth/register', oauthLimiter, (req, res) => {
49
- const redirectUris = Array.isArray(req.body?.redirect_uris) ? req.body.redirect_uris.map(String) : [];
50
- if (!redirectUris.length || redirectUris.some(uri => !validRedirectUri(uri))) {
51
- return res.status(400).json({ error: 'invalid_redirect_uri' });
52
- }
53
- if (req.body?.token_endpoint_auth_method && req.body.token_endpoint_auth_method !== 'none') {
54
- return res.status(400).json({ error: 'invalid_client_metadata', error_description: 'ReMCP requires PKCE public clients (token_endpoint_auth_method=none).' });
55
- }
56
- const clientId = `remcp_${randomToken(18)}`;
57
- saveOauthClient({ clientId, clientName: String(req.body?.client_name || 'MCP client').slice(0, 160), redirectUris });
58
- res.status(201).json({
59
- client_id: clientId,
60
- client_name: String(req.body?.client_name || 'MCP client').slice(0, 160),
61
- redirect_uris: redirectUris,
62
- token_endpoint_auth_method: 'none',
63
- grant_types: ['authorization_code', 'refresh_token'],
64
- response_types: ['code'],
65
- });
66
- });
67
-
68
- function validateAuthorize(values) {
69
- const client = getOauthClient(String(values.client_id || ''));
70
- if (!client) throw new Error('unknown_client');
71
- const redirectUri = String(values.redirect_uri || '');
72
- const allowed = JSON.parse(client.redirect_uris);
73
- if (!allowed.includes(redirectUri)) throw new Error('invalid_redirect_uri');
74
- if (values.response_type !== 'code') throw new Error('unsupported_response_type');
75
- if (values.code_challenge_method !== 'S256' || !values.code_challenge) throw new Error('pkce_s256_required');
76
- if (values.resource && values.resource !== endpoint('/mcp')) throw new Error('invalid_resource');
77
- return { client, redirectUri };
78
- }
79
-
80
- router.get('/oauth/authorize', (req, res, next) => {
81
- try { validateAuthorize(req.query); res.sendFile('authorize.html', { root: new URL('../public', import.meta.url).pathname }); }
82
- catch (error) { res.status(400).send(`OAuth request rejected: ${error.message}`); }
83
- });
84
-
85
- router.post('/oauth/authorize/complete', oauthLimiter, async (req, res) => {
86
- try {
87
- const { redirectUri } = validateAuthorize(req.body || {});
88
- const user = await verifyFirebaseToken(String(req.body?.id_token || ''));
89
- upsertUser(user);
90
- const rawCode = randomToken(32);
91
- createOauthCode({
92
- codeHash: sha256(rawCode), clientId: String(req.body.client_id), uid: user.uid,
93
- redirectUri, codeChallenge: String(req.body.code_challenge),
94
- scope: normalizeScope(req.body.scope), expiresAt: now() + CODE_TTL_MS,
95
- });
96
- const callback = new URL(redirectUri);
97
- callback.searchParams.set('code', rawCode);
98
- if (req.body.state) callback.searchParams.set('state', String(req.body.state));
99
- callback.searchParams.set('iss', config.publicUrl);
100
- res.json({ redirect_to: callback.toString() });
101
- } catch (error) {
102
- res.status(400).json({ error: 'access_denied', error_description: error.message });
103
- }
104
- });
105
-
106
- function noStore(res) { res.setHeader('Cache-Control', 'no-store'); res.setHeader('Pragma', 'no-cache'); }
107
- function pkce(verifier) { return crypto.createHash('sha256').update(verifier).digest('base64url'); }
108
-
109
- router.post('/oauth/token', tokenLimiter, async (req, res) => {
110
- noStore(res);
111
- try {
112
- const grant = String(req.body?.grant_type || '');
113
- if (grant === 'authorization_code') {
114
- const row = consumeOauthCode(sha256(String(req.body.code || '')));
115
- if (!row) throw new Error('invalid_or_expired_code');
116
- if (row.client_id !== String(req.body.client_id || '') || row.redirect_uri !== String(req.body.redirect_uri || '')) throw new Error('client_or_redirect_mismatch');
117
- if (pkce(String(req.body.code_verifier || '')) !== row.code_challenge) throw new Error('pkce_verification_failed');
118
- const accessToken = await signMcpAccessToken({ uid: row.uid, clientId: row.client_id, scope: row.scope });
119
- const refreshToken = randomToken(48);
120
- saveRefreshToken({ tokenHash: sha256(refreshToken), clientId: row.client_id, uid: row.uid, scope: row.scope, expiresAt: now() + config.refreshTtlSeconds * 1000 });
121
- return res.json({ access_token: accessToken, token_type: 'Bearer', expires_in: config.accessTtlSeconds, refresh_token: refreshToken, scope: row.scope });
122
- }
123
- if (grant === 'refresh_token') {
124
- const oldHash = sha256(String(req.body.refresh_token || ''));
125
- const row = getRefreshToken(oldHash);
126
- if (!row || row.client_id !== String(req.body.client_id || '')) throw new Error('invalid_refresh_token');
127
- revokeRefreshToken(oldHash);
128
- const scope = normalizeScope(req.body.scope || row.scope);
129
- const accessToken = await signMcpAccessToken({ uid: row.uid, clientId: row.client_id, scope });
130
- const refreshToken = randomToken(48);
131
- saveRefreshToken({ tokenHash: sha256(refreshToken), clientId: row.client_id, uid: row.uid, scope, expiresAt: now() + config.refreshTtlSeconds * 1000 });
132
- return res.json({ access_token: accessToken, token_type: 'Bearer', expires_in: config.accessTtlSeconds, refresh_token: refreshToken, scope });
133
- }
134
- res.status(400).json({ error: 'unsupported_grant_type' });
135
- } catch (error) {
136
- res.status(400).json({ error: 'invalid_grant', error_description: error.message });
137
- }
138
- });
139
-
140
-
141
- router.get('/oauth/userinfo', oauthLimiter, async (req, res) => {
142
- noStore(res);
143
- try {
144
- const token = bearer(req);
145
- if (!token) throw new Error('missing_token');
146
- const auth = await verifyMcpAccessToken(token);
147
- const scopes = new Set(auth.scope.split(/\s+/).filter(Boolean));
148
- if (!scopes.has('openid') || !scopes.has('email')) throw new Error('insufficient_scope');
149
- const user = getUser(auth.uid);
150
- if (!user?.email || user.email_verified !== 1) throw new Error('verified_email_required');
151
- res.json({ sub: auth.uid, email: user.email, email_verified: true, ...(user.name ? { name: user.name } : {}) });
152
- } catch (error) {
153
- res.setHeader('WWW-Authenticate', 'Bearer error="invalid_token"');
154
- res.status(401).json({ error: 'invalid_token', error_description: error.message });
155
- }
156
- });
157
-
158
- router.post('/oauth/revoke', oauthLimiter, (req, res) => {
159
- const token = String(req.body?.token || '');
160
- if (token) revokeRefreshToken(sha256(token));
161
- res.status(200).end();
162
- });
163
-
164
- export const oauthRouter = router;
package/src/relay.mjs DELETED
@@ -1,97 +0,0 @@
1
- import { WebSocketServer } from 'ws';
2
- import { config } from './config.mjs';
3
- import { getDeviceByTokenHash, listDevices, touchDevice } from './db.mjs';
4
- import { sha256 } from './util.mjs';
5
- import { REVIEW_SANDBOX_ID, reviewSandboxCall, reviewSandboxDevice } from './review-sandbox.mjs';
6
-
7
- export function createRelay(httpServer) {
8
- const wss = new WebSocketServer({ noServer: true });
9
- const agents = new Map();
10
- const pending = new Map();
11
- let seq = 0;
12
-
13
- httpServer.on('upgrade', (req, socket, head) => {
14
- const url = new URL(req.url || '/', 'http://localhost');
15
- if (url.pathname !== '/agent') return socket.destroy();
16
- const raw = req.headers.authorization || '';
17
- const token = raw.startsWith('Bearer ') ? raw.slice(7).trim() : url.searchParams.get('token') || '';
18
- const device = token ? getDeviceByTokenHash(sha256(token)) : undefined;
19
- if (!device) {
20
- socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
21
- return socket.destroy();
22
- }
23
- req.remcpDevice = device;
24
- wss.handleUpgrade(req, socket, head, ws => wss.emit('connection', ws, req));
25
- });
26
-
27
- wss.on('connection', (ws, req) => {
28
- const device = req.remcpDevice;
29
- const previous = agents.get(device.id);
30
- if (previous && previous.ws !== ws) previous.ws.close(1012, 'replaced');
31
- const agent = { ws, id: device.id, uid: device.uid, name: device.name, hostname: device.hostname, platform: device.platform, arch: device.arch, connectedAt: new Date().toISOString(), alive: true };
32
- agents.set(device.id, agent);
33
- touchDevice(device.id);
34
-
35
- ws.on('pong', () => { agent.alive = true; touchDevice(device.id); });
36
- ws.on('message', raw => {
37
- let message;
38
- try { message = JSON.parse(raw.toString()); } catch { return; }
39
- if (message?.type === 'hello') {
40
- agent.name = String(message.deviceName || agent.name);
41
- agent.hostname = String(message.hostname || agent.hostname || '');
42
- agent.platform = String(message.platform || agent.platform || '');
43
- agent.arch = String(message.arch || agent.arch || '');
44
- touchDevice(device.id, { name: agent.name, hostname: agent.hostname, platform: agent.platform, arch: agent.arch });
45
- return;
46
- }
47
- if (message?.type === 'response' && message.id) {
48
- const waiter = pending.get(message.id);
49
- if (!waiter) return;
50
- clearTimeout(waiter.timer); pending.delete(message.id);
51
- if (message.error) waiter.reject(new Error(message.error.message || 'Agent error'));
52
- else waiter.resolve(message.result);
53
- }
54
- });
55
- ws.on('close', () => {
56
- if (agents.get(device.id)?.ws === ws) agents.delete(device.id);
57
- for (const [id, waiter] of pending) if (waiter.deviceId === device.id) {
58
- clearTimeout(waiter.timer); pending.delete(id); waiter.reject(new Error(`Device disconnected: ${device.id}`));
59
- }
60
- });
61
- ws.on('error', () => {});
62
- });
63
-
64
- const heartbeat = setInterval(() => {
65
- for (const agent of agents.values()) {
66
- if (!agent.alive) { agent.ws.terminate(); continue; }
67
- agent.alive = false; agent.ws.ping();
68
- }
69
- }, 30000);
70
- heartbeat.unref();
71
-
72
- function rpc(uid, deviceId, method, params = {}) {
73
- if (deviceId === REVIEW_SANDBOX_ID) {
74
- if (method !== 'tools/call') throw new Error(`Unsupported Review Sandbox relay method: ${method}`);
75
- return Promise.resolve(reviewSandboxCall(uid, params.name, params.arguments || {}));
76
- }
77
- const agent = agents.get(deviceId);
78
- if (!agent || agent.uid !== uid || agent.ws.readyState !== 1) throw new Error(`Device is offline or unavailable: ${deviceId}`);
79
- return new Promise((resolve, reject) => {
80
- const id = `${Date.now()}-${++seq}`;
81
- const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Device request timed out: ${deviceId}`)); }, config.rpcTimeoutMs);
82
- pending.set(id, { resolve, reject, timer, deviceId });
83
- agent.ws.send(JSON.stringify({ type: 'request', id, method, params }));
84
- });
85
- }
86
-
87
- function devicesFor(uid) {
88
- const paired = listDevices(uid).map(device => ({
89
- ...device,
90
- online: agents.get(device.id)?.uid === uid,
91
- connectedAt: agents.get(device.id)?.connectedAt || null,
92
- }));
93
- return [reviewSandboxDevice(), ...paired];
94
- }
95
-
96
- return { rpc, devicesFor, onlineCount: () => agents.size };
97
- }
@@ -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
- }