@remcp/remcp 0.1.0 → 0.1.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 +104 -42
- package/assets/remcp-icon.png +0 -0
- package/mcp.json +9 -0
- package/package.json +4 -3
- package/plugin.json +37 -0
- package/public/assets/art/hero-relay.webp +0 -0
- package/public/assets/art/relay-detail.webp +0 -0
- package/public/authorize.html +4 -4
- package/public/copy.js +49 -0
- package/public/docs.html +50 -14
- package/public/index.html +29 -5
- package/public/privacy.html +6 -7
- package/public/security.html +4 -6
- package/public/style.css +46 -18
- package/public/support.html +8 -6
- package/public/terms.html +4 -6
- package/skills/remcp-operator/SKILL.md +1 -1
- package/src/agent.mjs +4 -3
- package/src/auth.mjs +1 -0
- package/src/cli.mjs +104 -32
- package/src/config.mjs +2 -2
- package/src/db.mjs +8 -5
- package/src/mcp.mjs +12 -7
- package/src/oauth.mjs +24 -5
- package/src/server.mjs +6 -5
- package/src/tool-catalog.json +24 -64
- package/src/util.mjs +1 -1
- package/src/version.mjs +8 -0
- package/chatgpt-app-submission.json +0 -356
package/public/style.css
CHANGED
|
@@ -87,6 +87,19 @@ main { display: block; }
|
|
|
87
87
|
.principles h2 { margin: 28px 0 9px; font-size: 21px; line-height: 1.2; letter-spacing: -.025em; }
|
|
88
88
|
.principles p { max-width: 330px; margin: 0; color: var(--muted); font-size: 15px; }
|
|
89
89
|
|
|
90
|
+
|
|
91
|
+
.quickstart { max-width: var(--container); margin: 0 auto; padding: clamp(72px, 9vw, 104px) 20px; border-top: 1px solid var(--line); }
|
|
92
|
+
.quickstart .section-heading { margin-left: 0; margin-right: 0; }
|
|
93
|
+
.quickstart-grid { display: grid; grid-template-columns: 1.35fr .8fr .8fr; gap: 0; margin: 34px 0 26px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
|
94
|
+
.command-card { min-width: 0; padding: 28px 28px 30px 0; }
|
|
95
|
+
.command-card + .command-card { padding-left: 28px; border-left: 1px solid var(--line); }
|
|
96
|
+
.command-card > span { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
97
|
+
.command-card h3 { margin: 18px 0 8px; font-size: 20px; letter-spacing: -.025em; }
|
|
98
|
+
.command-card p { margin: 12px 0 0; color: var(--muted); font-size: 14px; }
|
|
99
|
+
.command-card .command-row { margin-top: 14px; }
|
|
100
|
+
.site-header nav { min-width: 0; overflow-x: auto; scrollbar-width: none; }
|
|
101
|
+
.site-header nav::-webkit-scrollbar { display: none; }
|
|
102
|
+
|
|
90
103
|
.workspace { padding: clamp(72px, 9vw, 112px) 20px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: var(--surface); }
|
|
91
104
|
.workspace > * { max-width: var(--container); margin-left: auto; margin-right: auto; }
|
|
92
105
|
.section-heading { max-width: 720px; margin-bottom: 45px; }
|
|
@@ -107,7 +120,8 @@ input:focus-visible { border-color: var(--accent); }
|
|
|
107
120
|
.button-row { display: flex; gap: 9px; margin-top: 5px; }
|
|
108
121
|
.divider { display: flex; align-items: center; gap: 11px; margin: 7px 0; color: var(--muted); font-size: 12px; }
|
|
109
122
|
.divider::before, .divider::after { content: ""; flex: 1; height: 1px; background: var(--line); }
|
|
110
|
-
.error {
|
|
123
|
+
.error { margin: 2px 0 0; color: var(--danger); font-size: 13px; }
|
|
124
|
+
p:empty, .error:empty, #status:empty { display: none; }
|
|
111
125
|
.identity { display: flex; align-items: center; gap: 11px; }
|
|
112
126
|
.identity small { display: block; color: var(--muted); }
|
|
113
127
|
.status-dot { width: 9px; height: 9px; flex: 0 0 9px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 4px var(--ok-soft); }
|
|
@@ -141,24 +155,27 @@ input:focus-visible { border-color: var(--accent); }
|
|
|
141
155
|
.site-footer { max-width: var(--container); min-height: 86px; margin: 0 auto; padding: 22px 20px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 26px; border-top: 1px solid var(--line); font-size: 13px; }
|
|
142
156
|
.site-footer p { margin: 0; color: var(--muted); text-align: right; }
|
|
143
157
|
|
|
144
|
-
.doc-shell { width: min(100%, var(--container)); margin: 0 auto; padding:
|
|
158
|
+
.doc-shell { width: min(100%, var(--container)); margin: 0 auto; padding: 48px 20px 76px; display: grid; grid-template-columns: 176px minmax(0, 760px); gap: clamp(36px, 6vw, 72px); }
|
|
145
159
|
.doc-nav { position: sticky; top: 28px; align-self: start; display: flex; flex-direction: column; gap: 4px; }
|
|
146
160
|
.doc-nav a { padding: 8px 10px; border-radius: 7px; color: var(--muted); font-size: 14px; font-weight: 550; }
|
|
147
161
|
.doc-nav a:hover { background: var(--surface-subtle); color: var(--ink); }
|
|
148
162
|
.doc-nav a[aria-current="page"] { background: var(--surface-subtle); color: var(--ink); font-weight: 680; }
|
|
149
163
|
.prose { min-width: 0; }
|
|
150
|
-
.prose h1 { margin: 0 0
|
|
151
|
-
.prose h2 { scroll-margin-top:
|
|
152
|
-
.prose h3 { scroll-margin-top:
|
|
153
|
-
.prose p, .prose li { color: #505057; font-size:
|
|
154
|
-
.prose p { margin: 0 0
|
|
164
|
+
.prose h1 { margin: 0 0 18px; max-width: 700px; font-size: clamp(38px, 5vw, 54px); line-height: 1.04; letter-spacing: -.045em; }
|
|
165
|
+
.prose h2 { scroll-margin-top: 24px; margin: 36px 0 9px; font-size: 23px; line-height: 1.22; letter-spacing: -.03em; }
|
|
166
|
+
.prose h3 { scroll-margin-top: 24px; margin: 24px 0 7px; font-size: 17px; }
|
|
167
|
+
.prose p, .prose li { color: #505057; font-size: 15.5px; text-wrap: pretty; }
|
|
168
|
+
.prose p { margin: 0 0 12px; }
|
|
155
169
|
.prose a { color: #3538ae; text-decoration: underline; text-decoration-color: #a5a7e9; text-underline-offset: 3px; }
|
|
156
170
|
.prose a:hover { text-decoration-color: #3538ae; }
|
|
157
|
-
.prose pre { overflow: auto; margin:
|
|
171
|
+
.prose pre { min-width: 0; overflow: auto; margin: 0; padding: 13px 14px; background: transparent; color: #f4f4f2; }
|
|
172
|
+
.prose .code-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; margin: 12px 0 16px; overflow: hidden; border-radius: 10px; background: #1e1e21; }
|
|
173
|
+
.prose .code-row .code-copy { min-height: 34px; margin-right: 8px; padding: 7px 10px; border-color: #4b4b50; background: #2b2b2f; color: #f4f4f2; font-size: 12px; white-space: nowrap; }
|
|
174
|
+
.prose .code-row .code-copy:hover { border-color: #66666d; background: #38383d; }
|
|
158
175
|
.prose code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .9em; }
|
|
159
176
|
.prose :not(pre) > code { padding: 2px 5px; border-radius: 5px; background: var(--surface-subtle); color: #34343a; }
|
|
160
177
|
.prose hr { margin: 46px 0; border: 0; border-top: 1px solid var(--line); }
|
|
161
|
-
.prose .notice { margin:
|
|
178
|
+
.prose .notice { margin: 20px 0; padding: 13px 15px; border: 1px solid #d8d8f7; border-radius: 9px; background: var(--accent-soft); }
|
|
162
179
|
.prose .notice p { margin: 5px 0 0; }
|
|
163
180
|
|
|
164
181
|
.oauth-shell { min-height: calc(100svh - 68px); display: grid; place-items: center; padding: 56px 20px 80px; }
|
|
@@ -184,10 +201,13 @@ input:focus-visible, button:focus-visible, a:focus-visible { outline: 3px solid
|
|
|
184
201
|
.principles > div, .principles > div + div { min-height: auto; padding: 28px 0; border-left: 0; border-bottom: 1px solid var(--line); }
|
|
185
202
|
.principles > div:last-child { border-bottom: 0; }
|
|
186
203
|
.workspace-grid { grid-template-columns: 1fr; }
|
|
204
|
+
.quickstart-grid { grid-template-columns: 1fr; }
|
|
205
|
+
.command-card, .command-card + .command-card { padding: 24px 0; border-left: 0; border-bottom: 1px solid var(--line); }
|
|
206
|
+
.command-card:last-child { border-bottom: 0; }
|
|
187
207
|
.auth-pane { padding-right: 0; border-right: 0; border-bottom: 1px solid var(--line); }
|
|
188
208
|
.endpoint-pane { padding-left: 0; }
|
|
189
|
-
.doc-shell { grid-template-columns: 1fr; gap:
|
|
190
|
-
.doc-nav {
|
|
209
|
+
.doc-shell { grid-template-columns: 1fr; gap: 0; padding-top: 34px; }
|
|
210
|
+
.doc-nav { display: none; }
|
|
191
211
|
.site-footer { grid-template-columns: 1fr; }
|
|
192
212
|
.site-footer nav { flex-wrap: wrap; }
|
|
193
213
|
.site-footer p { text-align: left; }
|
|
@@ -196,7 +216,7 @@ input:focus-visible, button:focus-visible, a:focus-visible { outline: 3px solid
|
|
|
196
216
|
@media (max-width: 620px) {
|
|
197
217
|
.site-header { min-height: 62px; padding: 0 16px; }
|
|
198
218
|
.site-header { gap: 12px; }
|
|
199
|
-
.site-header nav { gap:
|
|
219
|
+
.site-header nav { max-width: calc(100vw - 116px); gap: 12px; }
|
|
200
220
|
.site-header nav a { font-size: 12.5px; }
|
|
201
221
|
.hero { padding: 58px 18px 64px; }
|
|
202
222
|
.hero h1 { font-size: clamp(46px, 14vw, 60px); }
|
|
@@ -205,21 +225,29 @@ input:focus-visible, button:focus-visible, a:focus-visible { outline: 3px solid
|
|
|
205
225
|
.node { align-items: flex-start; flex-direction: column; gap: 3px; }
|
|
206
226
|
.node.device-node { padding-right: 18px; }
|
|
207
227
|
.node .pulse { top: 15px; }
|
|
208
|
-
.workspace, .final-cta { padding
|
|
228
|
+
.workspace, .final-cta, .quickstart { padding: 50px 18px; }
|
|
229
|
+
.section-heading { margin-bottom: 30px; }
|
|
230
|
+
.section-heading h2, .final-cta h2 { font-size: 36px; }
|
|
231
|
+
.section-heading > p:last-child, .final-cta > p { margin-top: 12px; font-size: 16px; }
|
|
209
232
|
.button-row, .oauth-actions { flex-direction: column; }
|
|
210
233
|
.button-row .button, .oauth-actions .button { width: 100%; }
|
|
211
234
|
.pair-panel { grid-template-columns: 1fr; }
|
|
212
235
|
.pair-panel .button { justify-self: start; }
|
|
213
236
|
.panel-title { align-items: flex-start; flex-direction: column; }
|
|
214
|
-
.command-row, .pairing-output { grid-template-columns: 1fr; }
|
|
215
|
-
.copy-button { justify-self:
|
|
237
|
+
.command-row, .pairing-output { grid-template-columns: minmax(0, 1fr) auto; }
|
|
238
|
+
.copy-button { justify-self: stretch; }
|
|
216
239
|
.endpoint-facts div { align-items: flex-start; flex-direction: column; gap: 2px; }
|
|
217
240
|
.endpoint-facts dd { text-align: left; }
|
|
218
241
|
.site-footer { padding: 24px 18px; }
|
|
219
242
|
.site-footer nav { gap: 14px; }
|
|
220
|
-
.doc-shell { padding:
|
|
221
|
-
.
|
|
222
|
-
.
|
|
243
|
+
.doc-shell { gap: 18px; padding: 24px 16px 56px; }
|
|
244
|
+
.prose h1 { margin-bottom: 14px; font-size: clamp(34px, 10vw, 42px); }
|
|
245
|
+
.prose h2 { margin-top: 28px; font-size: 21px; }
|
|
246
|
+
.prose h3 { margin-top: 21px; }
|
|
247
|
+
.prose p, .prose li { font-size: 15px; }
|
|
248
|
+
.prose .code-row { margin: 10px 0 14px; }
|
|
249
|
+
.prose .code-row pre { padding: 12px 10px 12px 13px; }
|
|
250
|
+
.prose .code-row .code-copy { margin-right: 7px; }
|
|
223
251
|
.oauth-shell { min-height: calc(100svh - 62px); padding: 28px 14px 60px; place-items: start center; }
|
|
224
252
|
.oauth-card { padding: 22px 18px; }
|
|
225
253
|
.scope-list li { align-items: flex-start; flex-direction: column; gap: 4px; }
|
package/public/support.html
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<title>Support · ReMCP</title>
|
|
9
9
|
<meta name="description" content="Troubleshoot ReMCP sign-in, device pairing, agent connectivity and MCP authorization.">
|
|
10
10
|
<link rel="preconnect" href="https://www.gstatic.com" crossorigin>
|
|
11
|
-
<link rel="stylesheet" href="/style.css?v=
|
|
11
|
+
<link rel="stylesheet" href="/style.css?v=remcp-012">
|
|
12
12
|
<meta property="og:site_name" content="ReMCP">
|
|
13
13
|
<meta property="og:image" content="https://remcp.delio24.com/assets/og-remcp.png">
|
|
14
14
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
|
@@ -28,10 +28,10 @@
|
|
|
28
28
|
<img class="brand-mark" src="/android-chrome-192x192.png" width="30" height="30" alt="" aria-hidden="true">
|
|
29
29
|
<span translate="no">ReMCP</span>
|
|
30
30
|
</a>
|
|
31
|
-
<nav aria-label="Primary"><a href="/docs">Docs</a><a href="/security">Security</a><a href="/support" aria-current="page">Support</a></nav>
|
|
31
|
+
<nav aria-label="Primary"><a href="/docs">Docs</a><a href="/docs#install">Install</a><a href="/docs#update">Update</a><a href="/security">Security</a><a href="/support" aria-current="page">Support</a></nav>
|
|
32
32
|
</header>
|
|
33
33
|
<main id="main" class="doc-shell">
|
|
34
|
-
<aside class="doc-nav" aria-label="Documentation"><a href="/docs">Setup</a><a href="/security">Security</a><a href="/support" aria-current="page">Support</a><a href="/privacy">Privacy</a><a href="/terms">Terms</a></aside>
|
|
34
|
+
<aside class="doc-nav" aria-label="Documentation"><a href="/docs">Setup</a><a href="/docs#install">Install</a><a href="/docs#update">Update</a><a href="/security">Security</a><a href="/support" aria-current="page">Support</a><a href="/privacy">Privacy</a><a href="/terms">Terms</a></aside>
|
|
35
35
|
<article class="prose">
|
|
36
36
|
<p class="eyebrow">Support</p>
|
|
37
37
|
<h1>Troubleshoot ReMCP.</h1>
|
|
@@ -40,6 +40,9 @@
|
|
|
40
40
|
<h2>OAuth does not complete</h2><p>Return to the authorization screen, sign in to the intended ReMCP account, review the requested access, and choose <strong>Authorize ReMCP Access</strong>.</p>
|
|
41
41
|
<h2>MCP tool cannot run</h2><p>Confirm the target computer is online and use <code>list_devices</code> to obtain its current device id. Device tools require that id.</p>
|
|
42
42
|
<h2>Pairing code expired</h2><p>Generate a new pairing command from the workspace. Pairing codes are intentionally short-lived and single-use.</p>
|
|
43
|
+
<h2>Check the installed agent</h2><div class="code-row"><pre><code id="support-command-1">remcp --version
|
|
44
|
+
remcp status</code></pre><button class="copy-button code-copy" type="button" data-copy="#support-command-1" aria-label="Copy command" aria-live="polite">Copy</button></div><p>If <code>remcp</code> is not found, repeat the <a href="/docs#install">install command</a>. The first npx invocation is temporary; <code>--install</code> creates the persistent CLI.</p>
|
|
45
|
+
<h2>Update or repair</h2><div class="code-row"><pre><code id="support-command-2">remcp update</code></pre><button class="copy-button code-copy" type="button" data-copy="#support-command-2" aria-label="Copy command" aria-live="polite">Copy</button></div><p>This refreshes the published CLI and restarts the installed user service. For an already paired device whose service is missing, run <code>remcp install</code>.</p>
|
|
43
46
|
<h2>Need deployment help?</h2><p>For a self-hosted deployment, contact the operator who provisioned your ReMCP instance and include the affected layer: sign-in, pairing, agent connectivity, or MCP authorization.</p>
|
|
44
47
|
</article>
|
|
45
48
|
</main>
|
|
@@ -48,10 +51,9 @@
|
|
|
48
51
|
<img class="brand-mark" src="/android-chrome-192x192.png" width="30" height="30" alt="" aria-hidden="true">
|
|
49
52
|
<span translate="no">ReMCP</span>
|
|
50
53
|
</div>
|
|
51
|
-
<nav aria-label="Footer">
|
|
52
|
-
<a href="/docs">Docs</a><a href="/security">Security</a><a href="/support">Support</a><a href="/privacy">Privacy</a><a href="/terms">Terms</a>
|
|
53
|
-
</nav>
|
|
54
|
+
<nav aria-label="Footer"><a href="/docs">Docs</a><a href="/docs#install">Install</a><a href="/docs#update">Update</a><a href="/security">Security</a><a href="/support">Support</a><a href="/privacy">Privacy</a><a href="/terms">Terms</a></nav>
|
|
54
55
|
<p>Open-source remote MCP infrastructure.</p>
|
|
55
56
|
</footer>
|
|
57
|
+
<script type="module" src="/copy.js?v=remcp-012"></script>
|
|
56
58
|
</body>
|
|
57
59
|
</html>
|
package/public/terms.html
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<title>Terms of Service · ReMCP</title>
|
|
9
9
|
<meta name="description" content="Terms for using the hosted ReMCP service and its remote-computer capabilities.">
|
|
10
10
|
<link rel="preconnect" href="https://www.gstatic.com" crossorigin>
|
|
11
|
-
<link rel="stylesheet" href="/style.css?v=
|
|
11
|
+
<link rel="stylesheet" href="/style.css?v=remcp-012">
|
|
12
12
|
<meta property="og:site_name" content="ReMCP">
|
|
13
13
|
<meta property="og:image" content="https://remcp.delio24.com/assets/og-remcp.png">
|
|
14
14
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
|
@@ -28,10 +28,10 @@
|
|
|
28
28
|
<img class="brand-mark" src="/android-chrome-192x192.png" width="30" height="30" alt="" aria-hidden="true">
|
|
29
29
|
<span translate="no">ReMCP</span>
|
|
30
30
|
</a>
|
|
31
|
-
<nav aria-label="Primary"><a href="/docs">Docs</a><a href="/security">Security</a><a href="/support">Support</a></nav>
|
|
31
|
+
<nav aria-label="Primary"><a href="/docs">Docs</a><a href="/docs#install">Install</a><a href="/docs#update">Update</a><a href="/security">Security</a><a href="/support">Support</a></nav>
|
|
32
32
|
</header>
|
|
33
33
|
<main id="main" class="doc-shell">
|
|
34
|
-
<aside class="doc-nav" aria-label="
|
|
34
|
+
<aside class="doc-nav" aria-label="Documentation"><a href="/docs">Setup</a><a href="/docs#install">Install</a><a href="/docs#update">Update</a><a href="/security">Security</a><a href="/support">Support</a><a href="/privacy">Privacy</a><a href="/terms" aria-current="page">Terms</a></aside>
|
|
35
35
|
<article class="prose">
|
|
36
36
|
<p class="eyebrow">Legal</p>
|
|
37
37
|
<h1>Terms of Service</h1>
|
|
@@ -53,9 +53,7 @@
|
|
|
53
53
|
<img class="brand-mark" src="/android-chrome-192x192.png" width="30" height="30" alt="" aria-hidden="true">
|
|
54
54
|
<span translate="no">ReMCP</span>
|
|
55
55
|
</div>
|
|
56
|
-
<nav aria-label="Footer">
|
|
57
|
-
<a href="/docs">Docs</a><a href="/security">Security</a><a href="/support">Support</a><a href="/privacy">Privacy</a><a href="/terms">Terms</a>
|
|
58
|
-
</nav>
|
|
56
|
+
<nav aria-label="Footer"><a href="/docs">Docs</a><a href="/docs#install">Install</a><a href="/docs#update">Update</a><a href="/security">Security</a><a href="/support">Support</a><a href="/privacy">Privacy</a><a href="/terms">Terms</a></nav>
|
|
59
57
|
<p>Open-source remote MCP infrastructure.</p>
|
|
60
58
|
</footer>
|
|
61
59
|
</body>
|
|
@@ -33,4 +33,4 @@ Use `start_process` once and then `read_process_output` or `interact_with_proces
|
|
|
33
33
|
|
|
34
34
|
## Out-of-scope requests
|
|
35
35
|
|
|
36
|
-
Do not use ReMCP to access a computer the user has not paired or is not authorized to control. Do not ask for passwords, MFA codes, private keys, or other credentials through ReMCP
|
|
36
|
+
Do not use ReMCP to access a computer the user has not paired or is not authorized to control. Do not ask for or process passwords, MFA codes, private keys, API keys, payment-card data, protected health information, government identifiers, or other restricted credentials/data through ReMCP tools. If a requested file or command would expose those categories, ask the user to use a safer local workflow instead.
|
package/src/agent.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
import WebSocket from 'ws';
|
|
7
7
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
8
8
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
9
|
+
import { VERSION } from './version.mjs';
|
|
9
10
|
|
|
10
11
|
function desktopCommanderEntry() {
|
|
11
12
|
try { return fileURLToPath(import.meta.resolve('@wonderwhy-er/desktop-commander')); } catch {}
|
|
@@ -31,10 +32,10 @@ export async function runAgent(options) {
|
|
|
31
32
|
let stopping = false;
|
|
32
33
|
let activeSocket;
|
|
33
34
|
|
|
34
|
-
const mcp = new Client({ name: 'remcp-agent', version:
|
|
35
|
+
const mcp = new Client({ name: 'remcp-agent', version: VERSION });
|
|
35
36
|
const transport = new StdioClientTransport({ command: process.execPath, args: [desktopCommanderEntry()] });
|
|
36
37
|
await mcp.connect(transport);
|
|
37
|
-
console.log('Local
|
|
38
|
+
console.log('Local device runtime ready');
|
|
38
39
|
|
|
39
40
|
async function respond(ws, message) {
|
|
40
41
|
try {
|
|
@@ -54,7 +55,7 @@ export async function runAgent(options) {
|
|
|
54
55
|
const ws = new WebSocket(agentUrl, { headers: { Authorization: `Bearer ${deviceToken}` } });
|
|
55
56
|
activeSocket = ws;
|
|
56
57
|
ws.on('open', () => {
|
|
57
|
-
ws.send(JSON.stringify({ type: 'hello', deviceId, deviceName, hostname: os.hostname(), platform: process.platform, arch: process.arch, agentVersion:
|
|
58
|
+
ws.send(JSON.stringify({ type: 'hello', deviceId, deviceName, hostname: os.hostname(), platform: process.platform, arch: process.arch, agentVersion: VERSION }));
|
|
58
59
|
console.log(`Connected to ${agentUrl} as ${deviceName} (${deviceId})`);
|
|
59
60
|
});
|
|
60
61
|
ws.on('message', raw => {
|
package/src/auth.mjs
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -2,11 +2,15 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import process from 'node:process';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
5
6
|
import { runAgent } from './agent.mjs';
|
|
7
|
+
import { LOCAL_RUNTIME_NAME, LOCAL_RUNTIME_SPEC, PACKAGE_NAME, VERSION } from './version.mjs';
|
|
6
8
|
|
|
7
9
|
const home = os.homedir();
|
|
8
10
|
const configDir = process.env.REMCP_CONFIG_DIR || path.join(home, '.config', 'remcp');
|
|
9
11
|
const configFile = path.join(configDir, 'config.json');
|
|
12
|
+
const serviceFile = path.join(home, '.config', 'systemd', 'user', 'remcp-agent.service');
|
|
13
|
+
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
10
14
|
|
|
11
15
|
function parse(argv) {
|
|
12
16
|
const [command = 'help', ...rest] = argv;
|
|
@@ -18,73 +22,141 @@ function parse(argv) {
|
|
|
18
22
|
}
|
|
19
23
|
return { command, flags };
|
|
20
24
|
}
|
|
25
|
+
|
|
26
|
+
function run(command, args, options = {}) {
|
|
27
|
+
const result = spawnSync(command, args, { stdio: 'inherit', ...options });
|
|
28
|
+
if (result.error) throw result.error;
|
|
29
|
+
if (result.status !== 0) throw new Error(`${command} failed with exit code ${result.status}`);
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function output(command, args) {
|
|
34
|
+
const result = spawnSync(command, args, { encoding: 'utf8' });
|
|
35
|
+
if (result.error) throw result.error;
|
|
36
|
+
if (result.status !== 0) throw new Error(`${command} failed with exit code ${result.status}`);
|
|
37
|
+
return String(result.stdout || '').trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
21
40
|
function loadConfig() {
|
|
22
|
-
if (!fs.existsSync(configFile)) throw new Error(`ReMCP is not paired. Run:
|
|
41
|
+
if (!fs.existsSync(configFile)) throw new Error(`ReMCP is not paired. Run: npx --yes ${PACKAGE_NAME}@latest connect --server https://remcp.delio24.com --code CODE --install`);
|
|
23
42
|
return JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
24
43
|
}
|
|
44
|
+
|
|
25
45
|
function saveConfig(value) {
|
|
26
46
|
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
27
47
|
fs.writeFileSync(configFile, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
|
|
28
48
|
fs.chmodSync(configFile, 0o600);
|
|
29
49
|
}
|
|
30
|
-
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
50
|
+
|
|
51
|
+
function globalPrefix() {
|
|
52
|
+
return output(npmCommand, ['prefix', '--global']);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function globalCliPath() {
|
|
56
|
+
const prefix = globalPrefix();
|
|
57
|
+
return process.platform === 'win32' ? path.join(prefix, 'remcp.cmd') : path.join(prefix, 'bin', 'remcp');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function npmGlobalInstall(packageSpec) {
|
|
61
|
+
run(npmCommand, ['install', '--global', packageSpec, LOCAL_RUNTIME_SPEC, '--no-audit', '--no-fund', '--loglevel=error']);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function quoteSystemd(value) {
|
|
65
|
+
return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function installLinuxService(cliPath = globalCliPath()) {
|
|
69
|
+
const unit = `[Unit]\nDescription=ReMCP device agent\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=${quoteSystemd(cliPath)} start\nRestart=always\nRestartSec=3\nNoNewPrivileges=true\n\n[Install]\nWantedBy=default.target\n`;
|
|
70
|
+
fs.mkdirSync(path.dirname(serviceFile), { recursive: true });
|
|
71
|
+
fs.writeFileSync(serviceFile, unit);
|
|
72
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
73
|
+
run('systemctl', ['--user', 'enable', '--now', 'remcp-agent.service']);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function installPersistentAgent() {
|
|
77
|
+
if (process.platform !== 'linux') throw new Error('Automatic background service installation currently supports Linux');
|
|
78
|
+
console.log(`Installing ${PACKAGE_NAME}@${VERSION} and the local device runtime…`);
|
|
79
|
+
npmGlobalInstall(`${PACKAGE_NAME}@${VERSION}`);
|
|
80
|
+
installLinuxService(globalCliPath());
|
|
81
|
+
console.log('ReMCP is installed as a user service. Future updates: remcp update');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function restartLinuxServiceIfInstalled() {
|
|
85
|
+
if (process.platform !== 'linux' || !fs.existsSync(serviceFile)) return;
|
|
86
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
87
|
+
run('systemctl', ['--user', 'restart', 'remcp-agent.service']);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function uninstallLinuxService() {
|
|
91
|
+
if (process.platform !== 'linux') return;
|
|
92
|
+
spawnSync('systemctl', ['--user', 'disable', '--now', 'remcp-agent.service'], { stdio: 'inherit' });
|
|
93
|
+
try { fs.unlinkSync(serviceFile); } catch {}
|
|
36
94
|
spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
|
|
37
|
-
spawnSync('systemctl', ['--user', 'enable', '--now', 'remcp-agent.service'], { stdio: 'inherit' });
|
|
38
95
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (result.status !== 0) throw new Error(`${command} failed`);
|
|
43
|
-
return result;
|
|
44
|
-
}};
|
|
96
|
+
|
|
97
|
+
function printHelp() {
|
|
98
|
+
console.log(`ReMCP ${VERSION}\n\nCommands:\n npx --yes ${PACKAGE_NAME}@latest connect --server https://remcp.delio24.com --code ABC12345 --install\n remcp start\n remcp status\n remcp doctor\n remcp update\n remcp install\n remcp uninstall\n remcp uninstall --purge\n remcp --version`);
|
|
45
99
|
}
|
|
46
|
-
let childModule;
|
|
47
|
-
function requireChild() { if (!childModule) throw new Error('child_process not initialized'); return childModule; }
|
|
48
100
|
|
|
49
101
|
export async function main(argv = process.argv.slice(2)) {
|
|
50
|
-
childModule = await import('node:child_process');
|
|
51
102
|
const { command, flags } = parse(argv);
|
|
103
|
+
|
|
104
|
+
if (command === '--version' || command === '-v' || command === 'version') {
|
|
105
|
+
console.log(VERSION);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
52
109
|
if (command === 'connect') {
|
|
53
110
|
const server = String(flags.server || '').replace(/\/$/, '');
|
|
54
111
|
const code = String(flags.code || '').toUpperCase();
|
|
55
112
|
if (!server || !code) throw new Error('--server and --code are required');
|
|
56
113
|
const response = await fetch(`${server}/api/pair/claim`, {
|
|
57
|
-
method: 'POST',
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'content-type': 'application/json' },
|
|
58
116
|
body: JSON.stringify({ code, name: String(flags.name || os.hostname()), hostname: os.hostname(), platform: process.platform, arch: process.arch }),
|
|
59
117
|
});
|
|
60
118
|
if (!response.ok) throw new Error(`Pairing failed (${response.status}): ${await response.text()}`);
|
|
61
119
|
const paired = await response.json();
|
|
62
120
|
saveConfig({ serverUrl: server, deviceId: paired.deviceId, deviceToken: paired.deviceToken, deviceName: String(flags.name || os.hostname()) });
|
|
63
121
|
console.log(`Paired ${os.hostname()} with ${server}`);
|
|
64
|
-
if (flags.install)
|
|
65
|
-
if (process.platform === 'linux') installLinuxService();
|
|
66
|
-
else console.log('Automatic service installation currently supports Linux. Run `remcp start` or install a launch service for this user.');
|
|
67
|
-
}
|
|
122
|
+
if (flags.install) installPersistentAgent();
|
|
68
123
|
return;
|
|
69
124
|
}
|
|
70
|
-
|
|
125
|
+
|
|
126
|
+
if (command === 'start') {
|
|
127
|
+
await runAgent(loadConfig());
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
71
131
|
if (command === 'status' || command === 'doctor') {
|
|
72
132
|
const cfg = loadConfig();
|
|
73
|
-
const health = await fetch(`${cfg.serverUrl}/health
|
|
74
|
-
console.log(JSON.stringify({ configured: true, deviceId: cfg.deviceId, deviceName: cfg.deviceName, server: cfg.serverUrl, serverHealth: health }, null, 2));
|
|
133
|
+
const health = await fetch(`${cfg.serverUrl}/health?fresh=${Date.now()}`, { cache: 'no-store' }).then(r => r.json());
|
|
134
|
+
console.log(JSON.stringify({ configured: true, cliVersion: VERSION, deviceId: cfg.deviceId, deviceName: cfg.deviceName, server: cfg.serverUrl, serverHealth: health }, null, 2));
|
|
75
135
|
return;
|
|
76
136
|
}
|
|
137
|
+
|
|
77
138
|
if (command === 'install') {
|
|
78
|
-
|
|
79
|
-
|
|
139
|
+
loadConfig();
|
|
140
|
+
installPersistentAgent();
|
|
141
|
+
return;
|
|
80
142
|
}
|
|
143
|
+
|
|
144
|
+
if (command === 'update') {
|
|
145
|
+
console.log(`Updating ${PACKAGE_NAME} to the latest published version…`);
|
|
146
|
+
npmGlobalInstall(`${PACKAGE_NAME}@latest`);
|
|
147
|
+
restartLinuxServiceIfInstalled();
|
|
148
|
+
console.log('ReMCP updated. Run `remcp --version` or `remcp status` to verify.');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
81
152
|
if (command === 'uninstall') {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
153
|
+
uninstallLinuxService();
|
|
154
|
+
if (flags.purge) {
|
|
155
|
+
console.log('Removing global ReMCP packages…');
|
|
156
|
+
run(npmCommand, ['uninstall', '--global', PACKAGE_NAME, LOCAL_RUNTIME_NAME, '--no-audit', '--no-fund', '--loglevel=error']);
|
|
86
157
|
}
|
|
87
158
|
return;
|
|
88
159
|
}
|
|
89
|
-
|
|
160
|
+
|
|
161
|
+
printHelp();
|
|
90
162
|
}
|
package/src/config.mjs
CHANGED
|
@@ -22,7 +22,7 @@ function loadSigningSecret() {
|
|
|
22
22
|
return generated;
|
|
23
23
|
}
|
|
24
24
|
const tokenSecret = loadSigningSecret();
|
|
25
|
-
const installSpec = (process.env.REMCP_INSTALL_SPEC || '@remcp/remcp').trim();
|
|
25
|
+
const installSpec = (process.env.REMCP_INSTALL_SPEC || '@remcp/remcp@latest').trim();
|
|
26
26
|
if (!/^[A-Za-z0-9@._:/#-]+$/.test(installSpec)) throw new Error('REMCP_INSTALL_SPEC contains unsupported characters');
|
|
27
27
|
|
|
28
28
|
export const config = Object.freeze({
|
|
@@ -38,7 +38,7 @@ export const config = Object.freeze({
|
|
|
38
38
|
refreshTtlSeconds: Number(process.env.REFRESH_TTL_SECONDS || 2592000),
|
|
39
39
|
rpcTimeoutMs: Number(process.env.RPC_TIMEOUT_MS || 120000),
|
|
40
40
|
domainVerificationToken: (process.env.OPENAI_DOMAIN_VERIFICATION_TOKEN || '').trim(),
|
|
41
|
-
domainVerificationPath: (process.env.OPENAI_DOMAIN_VERIFICATION_PATH || '/.well-known/openai-
|
|
41
|
+
domainVerificationPath: (process.env.OPENAI_DOMAIN_VERIFICATION_PATH || '/.well-known/openai-apps-challenge').trim(),
|
|
42
42
|
firebase: {
|
|
43
43
|
apiKey: required('FIREBASE_API_KEY'),
|
|
44
44
|
authDomain: required('FIREBASE_AUTH_DOMAIN'),
|
package/src/db.mjs
CHANGED
|
@@ -9,7 +9,7 @@ export const db = new DatabaseSync(join(config.dataDir, 'remcp.sqlite'));
|
|
|
9
9
|
db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;`);
|
|
10
10
|
db.exec(`
|
|
11
11
|
CREATE TABLE IF NOT EXISTS users (
|
|
12
|
-
uid TEXT PRIMARY KEY, email TEXT, name TEXT, created_at INTEGER NOT NULL, last_login_at INTEGER NOT NULL
|
|
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
13
|
);
|
|
14
14
|
CREATE TABLE IF NOT EXISTS pair_codes (
|
|
15
15
|
code_hash TEXT PRIMARY KEY, uid TEXT NOT NULL REFERENCES users(uid) ON DELETE CASCADE,
|
|
@@ -34,12 +34,15 @@ CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
|
|
|
34
34
|
scope TEXT NOT NULL, expires_at INTEGER NOT NULL, revoked_at INTEGER
|
|
35
35
|
);
|
|
36
36
|
`);
|
|
37
|
+
try { db.exec('ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0'); } catch {}
|
|
37
38
|
|
|
38
|
-
export
|
|
39
|
+
export const getUser = uid => db.prepare('SELECT * FROM users WHERE uid=?').get(uid);
|
|
40
|
+
|
|
41
|
+
export function upsertUser({ uid, email = '', name = '', emailVerified = false }) {
|
|
39
42
|
const t = now();
|
|
40
|
-
db.prepare(`INSERT INTO users(uid,email,name,created_at,last_login_at) VALUES(
|
|
41
|
-
ON CONFLICT(uid) DO UPDATE SET email=excluded.email,name=excluded.name,last_login_at=excluded.last_login_at`)
|
|
42
|
-
.run(uid, email, name, t, t);
|
|
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);
|
|
43
46
|
return db.prepare('SELECT * FROM users WHERE uid=?').get(uid);
|
|
44
47
|
}
|
|
45
48
|
|
package/src/mcp.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { VERSION } from './version.mjs';
|
|
4
5
|
|
|
5
6
|
const rawCatalog = JSON.parse(readFileSync(new URL('./tool-catalog.json', import.meta.url), 'utf8'));
|
|
6
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']);
|
|
7
8
|
const openWorld = new Set(['start_process','interact_with_process']);
|
|
8
9
|
const descriptions = {
|
|
9
|
-
get_config: 'Read the local
|
|
10
|
-
set_config_value: 'Change one supported
|
|
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.',
|
|
11
12
|
read_file: 'Read a local file or a user-supplied URL from the selected paired device.',
|
|
12
13
|
read_multiple_files: 'Read multiple local files from the selected paired device in one call.',
|
|
13
14
|
write_file: 'Create, replace, or append to a file on the selected paired device.',
|
|
@@ -28,10 +29,14 @@ const descriptions = {
|
|
|
28
29
|
list_sessions: 'List active terminal sessions on the selected paired device.',
|
|
29
30
|
list_processes: 'List running operating-system processes on the selected paired device.',
|
|
30
31
|
kill_process: 'Terminate a process by PID on the selected paired device.',
|
|
31
|
-
get_usage_stats: 'Read local
|
|
32
|
-
get_recent_tool_calls: 'Read recent local
|
|
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.',
|
|
33
34
|
};
|
|
34
35
|
|
|
36
|
+
function publicDescription(tool) {
|
|
37
|
+
return String(descriptions[tool.name] || tool.description || '').replace(/\s+/g, ' ').trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
35
40
|
function publicTool(tool) {
|
|
36
41
|
const schema = structuredClone(tool.inputSchema || { type: 'object', properties: {} });
|
|
37
42
|
schema.type = 'object';
|
|
@@ -45,14 +50,14 @@ function publicTool(tool) {
|
|
|
45
50
|
return {
|
|
46
51
|
name: tool.name,
|
|
47
52
|
title: upstream.title || tool.name,
|
|
48
|
-
description:
|
|
53
|
+
description: publicDescription(tool),
|
|
49
54
|
inputSchema: schema,
|
|
50
55
|
annotations: {
|
|
51
56
|
title: upstream.title || tool.name,
|
|
52
57
|
readOnlyHint: tool.name === 'start_search' ? false : upstream.readOnlyHint === true,
|
|
53
58
|
destructiveHint: destructive.has(tool.name),
|
|
54
59
|
idempotentHint: upstream.idempotentHint === true,
|
|
55
|
-
openWorldHint: tool.name === 'read_file'
|
|
60
|
+
openWorldHint: openWorld.has(tool.name) || tool.name === 'read_file' || upstream.openWorldHint === true,
|
|
56
61
|
},
|
|
57
62
|
};
|
|
58
63
|
}
|
|
@@ -68,7 +73,7 @@ const listDevicesTool = {
|
|
|
68
73
|
};
|
|
69
74
|
|
|
70
75
|
export function createMcpServer(uid, relay) {
|
|
71
|
-
const server = new Server({ name: 'remcp', version:
|
|
76
|
+
const server = new Server({ name: 'remcp', version: VERSION }, { capabilities: { tools: {} } });
|
|
72
77
|
|
|
73
78
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [listDevicesTool, ...catalog] }));
|
|
74
79
|
server.setRequestHandler(CallToolRequestSchema, async request => {
|
package/src/oauth.mjs
CHANGED
|
@@ -2,9 +2,9 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import express from 'express';
|
|
3
3
|
import { rateLimit } from 'express-rate-limit';
|
|
4
4
|
import { config } from './config.mjs';
|
|
5
|
-
import { verifyFirebaseToken, signMcpAccessToken } from './auth.mjs';
|
|
6
|
-
import { createOauthCode, consumeOauthCode, getOauthClient, getRefreshToken, revokeRefreshToken, saveOauthClient, saveRefreshToken, upsertUser } from './db.mjs';
|
|
7
|
-
import { normalizeScope, now, randomId, randomToken, sha256, validRedirectUri } from './util.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
8
|
|
|
9
9
|
const CODE_TTL_MS = 5 * 60 * 1000;
|
|
10
10
|
const router = express.Router();
|
|
@@ -18,12 +18,13 @@ function metadata() {
|
|
|
18
18
|
authorization_endpoint: endpoint('/oauth/authorize'),
|
|
19
19
|
token_endpoint: endpoint('/oauth/token'),
|
|
20
20
|
registration_endpoint: endpoint('/oauth/register'),
|
|
21
|
+
userinfo_endpoint: endpoint('/oauth/userinfo'),
|
|
21
22
|
revocation_endpoint: endpoint('/oauth/revoke'),
|
|
22
23
|
response_types_supported: ['code'],
|
|
23
24
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
24
25
|
token_endpoint_auth_methods_supported: ['none'],
|
|
25
26
|
code_challenge_methods_supported: ['S256'],
|
|
26
|
-
scopes_supported: ['remcp:control', 'offline_access'],
|
|
27
|
+
scopes_supported: ['openid', 'email', 'remcp:control', 'offline_access'],
|
|
27
28
|
};
|
|
28
29
|
}
|
|
29
30
|
|
|
@@ -32,7 +33,7 @@ function protectedResource() {
|
|
|
32
33
|
resource: endpoint('/mcp'),
|
|
33
34
|
authorization_servers: [config.publicUrl],
|
|
34
35
|
bearer_methods_supported: ['header'],
|
|
35
|
-
scopes_supported: ['remcp:control', 'offline_access'],
|
|
36
|
+
scopes_supported: ['openid', 'email', 'remcp:control', 'offline_access'],
|
|
36
37
|
resource_documentation: config.publicUrl,
|
|
37
38
|
resource_policy_uri: endpoint('/privacy'),
|
|
38
39
|
resource_tos_uri: endpoint('/terms'),
|
|
@@ -136,6 +137,24 @@ router.post('/oauth/token', tokenLimiter, async (req, res) => {
|
|
|
136
137
|
}
|
|
137
138
|
});
|
|
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
|
+
|
|
139
158
|
router.post('/oauth/revoke', oauthLimiter, (req, res) => {
|
|
140
159
|
const token = String(req.body?.token || '');
|
|
141
160
|
if (token) revokeRefreshToken(sha256(token));
|