Repository navigation
Expand file tree
/
Copy pathmcpcube.php
More file actions
440 lines (374 loc) · 21.8 KB
/
Copy pathmcpcube.php
File metadata and controls
440 lines (374 loc) · 21.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
<?php
declare(strict_types=1);
// Needed for plugin-only installations; Roundcube's root autoloader already
// loads this when the plugin was installed with Composer.
@include_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/lib/mcpcube_crypto.php';
require_once __DIR__ . '/lib/mcpcube_mcp_protocol.php';
require_once __DIR__ . '/lib/mcpcube_auth_context.php';
require_once __DIR__ . '/lib/mcpcube_device_flow.php';
require_once __DIR__ . '/lib/mcpcube_oauth.php';
require_once __DIR__ . '/lib/mcpcube_sandbox.php';
require_once __DIR__ . '/lib/mcpcube_api_mail.php';
require_once __DIR__ . '/lib/mcpcube_api_contacts.php';
require_once __DIR__ . '/lib/mcpcube_api_identities.php';
/**
* MCPcube: a Model Context Protocol server, implemented as a Roundcube
* plugin, that lets an authorized AI agent read/send/delete mail, manage
* contacts, and manage identities on the user's behalf.
*
* Architecture in one paragraph: an agent pairs via the RFC 8628 Device
* Authorization Grant (see lib/mcpcube_device_flow.php) to get a 30-day
* bearer token, tied server-side to an AES-256-GCM-encrypted copy of the
* user's IMAP password (see lib/mcpcube_crypto.php and
* lib/mcpcube_auth_context.php). Every MCP call re-authenticates from that
* token, stateless, via rcmail::login(). The MCP surface itself is a single
* "code mode" tool, execute_script (see lib/mcpcube_sandbox.php): instead of
* exposing dozens of granular tools, the agent writes a short sandboxed
* PHP-subset script that calls $mail/$contacts/$identities (see
* lib/mcpcube_api_*.php), so multi-step tasks take one round trip instead of
* many. Every scope check and the two-step delete-confirmation-token pattern
* (lib/mcpcube_mcp_protocol.php's mcpcube_confirmation) live inside those API
* facades, not the protocol layer, since they must apply identically however
* a script chooses to call them.
*/
class mcpcube extends rcube_plugin
{
public $task = 'login|settings';
private const MCP_ACTION = 'plugin.mcpcube';
private const PAIR_ACTION = 'plugin.mcpcube-pair';
private const AGENTS_ACTION = 'plugin.mcpcube-agents';
private const AGENTS_REVOKE_ACTION = 'plugin.mcpcube-agents-revoke';
private const OAUTH_TOKEN_ACTION = 'plugin.mcpcube-oauth-token';
private const OAUTH_AUTHORIZE_ACTION = 'plugin.mcpcube-oauth-authorize';
private const OAUTH_REGISTER_ACTION = 'plugin.mcpcube-oauth-register';
public function init(): void
{
$this->load_config('config.inc.php.dist');
$this->load_config('config.inc.php');
// Unauthenticated JSON API, reachable under task=login exactly like
// this plugin's own MCP bearer-token auth requires - Roundcube's
// session/login wall is deliberately bypassed here in favor of our
// own per-request token check in mcpcube_auth_context.
$this->register_action(self::MCP_ACTION, [$this, 'mcpEndpoint']);
$this->register_action(self::OAUTH_TOKEN_ACTION, [$this, 'oauthToken']);
$this->register_action(self::OAUTH_REGISTER_ACTION, [$this, 'oauthRegister']);
$this->register_action(self::OAUTH_AUTHORIZE_ACTION, [$this, 'oauthAuthorize']);
// Roundcube renders the login page before normal action dispatch when
// there is no authenticated web session. Dispatch our independently
// authenticated machine endpoints from startup so they are actually
// reachable without weakening the settings/login wall.
$this->add_hook('startup', [$this, 'startup']);
// Human-facing pages, reachable under task=settings so Roundcube's
// normal auth wall requires a real login first.
$this->register_action(self::PAIR_ACTION, [$this, 'pair']);
$this->register_action(self::AGENTS_ACTION, [$this, 'agentsSettings']);
$this->register_action(self::AGENTS_REVOKE_ACTION, [$this, 'revokeAgent']);
$this->add_hook('settings_actions', [$this, 'settingsActions']);
}
/** @param array<string, mixed> $args @return array<string, mixed> */
public function startup(array $args): array
{
if (($args['task'] ?? '') !== 'login') {
return $args;
}
match ($args['action'] ?? '') {
self::MCP_ACTION => $this->mcpEndpoint(),
self::OAUTH_TOKEN_ACTION => $this->oauthToken(),
self::OAUTH_REGISTER_ACTION => $this->oauthRegister(),
self::OAUTH_AUTHORIZE_ACTION => $this->oauthAuthorize(),
default => $this->wellKnownDispatch(),
};
return $args;
}
public function oauthToken(): void { mcpcube_oauth::token(); }
public function oauthRegister(): void { mcpcube_oauth::register(); }
public function oauthAuthorize(): void { mcpcube_oauth::authorize(); }
private function wellKnownDispatch(): void
{
$path = (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
if ($path === mcpcube_oauth::PRM_PATH) mcpcube_oauth::json(mcpcube_oauth::metadata(true));
if ($path === mcpcube_oauth::AS_PATH) mcpcube_oauth::json(mcpcube_oauth::metadata());
}
public function pair(): void
{
(new mcpcube_device_flow())->consent();
}
/** @param array<string, mixed> $args @return array<string, mixed> */
public function settingsActions(array $args): array
{
$args['actions'][] = [
'action' => self::AGENTS_ACTION,
'type' => 'link',
'label' => 'MCPcube Agents',
'title' => 'Manage AI agents authorized to access this mailbox',
];
return $args;
}
// -----------------------------------------------------------
// The MCP JSON-RPC endpoint itself
// -----------------------------------------------------------
public function mcpEndpoint(): void
{
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
if ($method !== 'POST') {
header('Allow: POST');
$this->json_out(405, ['jsonrpc' => '2.0', 'id' => null, 'error' => ['code' => -32600, 'message' => 'Use POST']]);
}
$this->validate_origin();
$token = mcpcube_auth_context::bearer_token_from_request();
if ($token === null) {
$base = rtrim((string) rcmail::get_instance()->config->get('mcpcube_public_url', ''), '/');
header('WWW-Authenticate: Bearer resource_metadata="' . $base . '/.well-known/oauth-protected-resource", scope="mail.read"');
$this->json_out(401, ['jsonrpc' => '2.0', 'id' => null, 'error' => [
'code' => -32001,
'message' => 'Missing bearer token. Use MCP OAuth authorization with PKCE.',
]]);
}
try {
$ctx = mcpcube_auth_context::from_bearer_token($token);
} catch (mcpcube_auth_error $e) {
if ($e->httpStatus === 401) {
header('WWW-Authenticate: Bearer error="invalid_token"');
}
$this->json_out($e->httpStatus, ['jsonrpc' => '2.0', 'id' => null, 'error' => ['code' => -32001, 'message' => $e->getMessage()]]);
}
$contentType = strtolower(trim(explode(';', (string) ($_SERVER['CONTENT_TYPE'] ?? ''))[0]));
if ($contentType !== 'application/json') {
$this->json_out(415, ['jsonrpc' => '2.0', 'id' => null, 'error' => ['code' => -32600, 'message' => 'Content-Type must be application/json']]);
}
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 1048576) {
$this->json_out(413, ['jsonrpc' => '2.0', 'id' => null, 'error' => ['code' => -32600, 'message' => 'Request body is too large']]);
}
$raw = file_get_contents('php://input', false, null, 0, 1048577);
$decoded = is_string($raw) ? json_decode($raw, true) : null;
if (!is_array($decoded) || array_is_list($decoded)) {
$this->json_out(400, ['jsonrpc' => '2.0', 'id' => null, 'error' => ['code' => -32700, 'message' => 'Invalid JSON']]);
}
$this->validate_protocol_header($decoded);
$server = $this->build_mcp_server();
$response = $server->handle($decoded, $ctx);
if ($response === null) {
$ctx->close();
http_response_code(202);
header('Cache-Control: no-store');
exit;
}
$ctx->close();
$this->json_out(200, $response);
}
private function validate_origin(): void
{
$origin = (string) ($_SERVER['HTTP_ORIGIN'] ?? '');
if ($origin === '') {
return;
}
$publicUrl = (string) rcmail::get_instance()->config->get('mcpcube_public_url', '');
$parts = parse_url($publicUrl);
$expected = is_array($parts) && isset($parts['scheme'], $parts['host'])
? strtolower($parts['scheme']) . '://' . strtolower($parts['host'])
. (isset($parts['port']) ? ':' . (int) $parts['port'] : '')
: '';
if ($expected === '' || !hash_equals($expected, strtolower(rtrim($origin, '/')))) {
$this->json_out(403, ['jsonrpc' => '2.0', 'id' => null, 'error' => ['code' => -32000, 'message' => 'Forbidden Origin']]);
}
}
/** @param array<string, mixed> $request */
private function validate_protocol_header(array $request): void
{
if (($request['method'] ?? null) === 'initialize') {
return;
}
$version = (string) ($_SERVER['HTTP_MCP_PROTOCOL_VERSION'] ?? '2025-03-26');
if (!in_array($version, mcpcube_mcp_server::SUPPORTED_PROTOCOL_VERSIONS, true)) {
$this->json_out(400, ['jsonrpc' => '2.0', 'id' => $request['id'] ?? null, 'error' => [
'code' => -32600,
'message' => 'Unsupported MCP-Protocol-Version',
]]);
}
}
private function build_mcp_server(): mcpcube_mcp_server
{
$server = new mcpcube_mcp_server();
$server->register_tool(
'execute_script',
$this->execute_script_description(),
[
'type' => 'object',
'properties' => [
'script' => [
'type' => 'string',
'description' => 'PHP-subset source. Do not include an opening <?php tag.',
],
],
'required' => ['script'],
],
null, // no single blanket scope - each $mail/$contacts/$identities call checks its own
function (array $args, mcpcube_auth_context $ctx): array {
$script = (string) ($args['script'] ?? '');
if (trim($script) === '') {
throw new mcpcube_tool_error('script must not be empty.');
}
$api = [
'mail' => new mcpcube_api_mail($ctx),
'contacts' => new mcpcube_api_contacts($ctx),
'identities' => new mcpcube_api_identities($ctx),
];
try {
$result = mcpcube_sandbox::run($script, $api);
} catch (mcpcube_sandbox_error | mcpcube_sandbox_runtime_error $e) {
// Surface the exact, actionable message (syntax error and
// line number, disallowed construct, budget exceeded,
// unknown API call, ...) back to the agent so it can fix
// and retry, rather than a generic failure.
throw new mcpcube_tool_error($e->getMessage());
}
return mcpcube_mcp_server::json_result([
'return' => $result['return'],
'echo' => $result['output'],
]);
}
);
return $server;
}
private function execute_script_description(): string
{
return <<<'DESC'
Run a short PHP-subset script and return its result. This is the ONLY tool: every mailbox action (read folders/mail, send mail, manage contacts, manage identities) happens by writing a script that calls methods on three pre-defined objects: $mail, $contacts, $identities. There is no separate "list_messages" or "send_message" tool - call $mail->listMessages(...) etc. from inside a script instead. Prefer writing one script that does several steps (loops, conditionals, multiple calls) over calling execute_script many times.
Language: a safe subset of PHP. Supported: variables, arrays (incl. nested, associative), string interpolation, if/elseif/else, foreach/for/while/do-while, break/continue, try/catch/finally (catch (Exception $e) catches any error from the API; $e is an array with a "message" key, not a real exception object), return, echo, arithmetic/comparison/boolean operators, ternary, ??, isset/empty, (int)/(string)/(bool)/(array)/(float) casts. NOT supported: function/class declarations, closures, `new`, plain function calls like count()/strtoupper() (none exist in this sandbox - use loops/operators instead), include/require, eval, globals, static/superglobals, dynamic ($$x) or by-reference variables. A script using anything unsupported is rejected before it runs, with a line number, instead of partially executing. Scripts have a step count and a few-second wall-clock budget; keep loops bounded. Use `return $value;` to send a result back - it becomes this tool's JSON result (under the "return" key, alongside any "echo" output for debugging).
Every call below only succeeds if this agent was granted the matching OAuth scope when the user approved it (mail.read/mail.write/mail.delete, contacts.read/write/delete, settings.read/write/delete); otherwise it throws a catchable error naming the missing scope.
$mail:
listFolders(): [{name, unread, total, delimiter}]
listMessages(folder = 'INBOX', limit = 100, offset = 0): [{uid, folder, subject, from, to, date, size, seen, flagged, answered}], newest first
getMessage(uid, folder = 'INBOX'): {uid, folder, subject, from, to, cc, date, message_id, body_text, attachments: [{filename, mimetype, size, part_id}]}
searchMessages(query, folder = 'INBOX', limit = 50): same shape as listMessages
sendMessage(['to' => ..., 'subject' => ..., 'cc' => ?, 'bcc' => ?, 'body_text' => ?, 'body_html' => ?, 'in_reply_to' => ?, 'references' => ?]): {status, to, subject, message_id}
moveMessage(uid, folder, targetFolder): {status, uid, from, to}
flagMessage(uid, folder, flag, set = true): flag is one of SEEN|FLAGGED|ANSWERED|DRAFT
createFolder(name, parent = null): {status, name}
deleteMessage(uid, folder = 'INBOX', confirm = false, confirmationToken = null): call once WITHOUT confirm to get {status: 'confirmation_required', preview, confirmation_token}; a separate execute_script call with confirm: true and that exact token performs the deletion. uid may be a single int or an array of ints.
emptyFolder(folder, confirm = false, confirmationToken = null): same two-step pattern; permanently removes every message in the folder.
deleteFolder(name, confirm = false, confirmationToken = null): same two-step pattern.
$contacts:
listContacts(search = null, limit = 50, offset = 0): [{id, name, email}]
getContact(id): {id, name, email, firstname, surname, organization, phone, notes}
createContact(['name' => ?, 'firstname' => ?, 'surname' => ?, 'organization' => ?, 'email' => ..., 'phone' => ?, 'notes' => ?]): {status, id}
updateContact(id, fields): same fields as createContact, {status, id}
deleteContact(id, confirm = false, confirmationToken = null): two-step pattern like deleteMessage; id may be a single id or an array of ids.
$identities:
whoami(): {username, primary_email, agent_id, agent_label, scopes, server} - always available, no scope required.
listIdentities(): [{id, name, email, is_default}]
getIdentity(id): {id, name, email, is_default, organization, reply_to, bcc, signature}
createIdentity(['name' => ..., 'email' => ..., 'organization' => ?, 'reply_to' => ?, 'bcc' => ?, 'signature' => ?]): {status, id}
updateIdentity(id, fields): same fields as createIdentity, {status, id}
deleteIdentity(id, confirm = false, confirmationToken = null): two-step pattern like deleteMessage.
Example - collect unread subjects from the inbox in one round trip:
$unread = [];
foreach ($mail->listMessages('INBOX', 100) as $m) {
if (!$m['seen']) {
$unread[] = $m['subject'];
}
}
return $unread;
Example - two-step delete (call execute_script twice):
// call 1:
return $mail->deleteMessage(12345, 'INBOX');
// inspect the returned preview + confirmation_token, then call 2:
return $mail->deleteMessage(12345, 'INBOX', true, '<confirmation_token from call 1>');
DESC;
}
/** @param array<int|string, mixed>|object $body */
private function json_out(int $status, array|object $body): never
{
http_response_code($status);
header('Content-Type: application/json');
header('Cache-Control: no-store');
echo json_encode($body, JSON_UNESCAPED_SLASHES);
exit;
}
// -----------------------------------------------------------
// Settings: list / revoke authorized agents
// -----------------------------------------------------------
public function agentsSettings(): void
{
$rcmail = rcmail::get_instance();
$this->register_output_handler('plugin.body', [$this, 'agentsSettingsBody']);
$rcmail->output->set_pagetitle('MCPcube Agents');
$rcmail->output->send('plugin');
}
public function agentsSettingsBody(): string
{
$rcmail = rcmail::get_instance();
$db = $rcmail->get_dbh();
$table = $db->table_name('mcpcube_agents', true);
$result = $db->query(
"SELECT `id`, `label`, `scopes`, `created`, `expires`, `last_used`, `revoked` FROM {$table}"
. ' WHERE `user_id` = ? ORDER BY `created` DESC',
(int) $rcmail->user->ID
);
$rows = '';
while (is_array($agent = $db->fetch_assoc($result))) {
$status = $agent['revoked'] !== null
? 'Revoked'
: (strtotime((string) $agent['expires']) < time() ? 'Expired' : 'Active');
$revokeButton = $agent['revoked'] !== null ? '' : html::tag('form', [
'method' => 'post',
'action' => $rcmail->url(['_task' => 'settings', '_action' => self::AGENTS_REVOKE_ACTION]),
'style' => 'display:inline',
], html::tag('input', ['type' => 'hidden', 'name' => '_token', 'value' => $rcmail->get_request_token()])
. html::tag('input', ['type' => 'hidden', 'name' => '_id', 'value' => (string) $agent['id']])
. html::tag('button', ['type' => 'submit', 'class' => 'button'], html::quote('Revoke')));
$rows .= html::tag('tr', [], ''
. html::tag('td', [], html::quote((string) $agent['label']))
. html::tag('td', [], html::quote(str_replace(',', ', ', (string) $agent['scopes'])))
. html::tag('td', [], html::quote((string) $agent['created']))
. html::tag('td', [], html::quote((string) $agent['expires']))
. html::tag('td', [], html::quote((string) ($agent['last_used'] ?? 'never')))
. html::tag('td', [], html::quote($status))
. html::tag('td', [], $revokeButton));
}
if ($rows === '') {
$rows = html::tag('tr', [], html::tag('td', ['colspan' => 7], html::quote('No agents have been authorized yet.')));
}
$tableHtml = html::tag('table', ['class' => 'records-table'],
html::tag('thead', [], html::tag('tr', [], ''
. html::tag('th', [], html::quote('Agent'))
. html::tag('th', [], html::quote('Scopes'))
. html::tag('th', [], html::quote('Authorized'))
. html::tag('th', [], html::quote('Expires'))
. html::tag('th', [], html::quote('Last used'))
. html::tag('th', [], html::quote('Status'))
. html::tag('th', [], '')))
. html::tag('tbody', [], $rows));
$body = html::tag('h2', [], html::quote('MCPcube Agents'))
. html::p([], html::quote(
'AI agents currently (or previously) authorized to access this mailbox via MCPcube. '
. 'Revoking an agent immediately invalidates its access; the agent must be paired again afterward.'
))
. $tableHtml;
return html::div(['class' => 'boxcontent formcontent'], $body);
}
public function revokeAgent(): void
{
$rcmail = rcmail::get_instance();
if (!$rcmail->check_request(rcube_utils::INPUT_POST)) {
$rcmail->output->show_message('This request could not be verified.', 'error');
$this->agentsSettings();
return;
}
$id = (int) rcube_utils::get_input_value('_id', rcube_utils::INPUT_POST);
$db = $rcmail->get_dbh();
$table = $db->table_name('mcpcube_agents', true);
$db->query(
"UPDATE {$table} SET `revoked` = " . $db->now() . ' WHERE `id` = ? AND `user_id` = ? AND `revoked` IS NULL',
$id,
(int) $rcmail->user->ID
);
$rcmail->output->show_message('Agent revoked.', 'confirmation');
$rcmail->output->command('redirect', $rcmail->url(['_task' => 'settings', '_action' => self::AGENTS_ACTION]));
}
private function register_output_handler(string $name, callable $callback): void
{
rcmail::get_instance()->output->add_handler($name, $callback);
}
}