@kya-os/mcp-i-cloudflare 1.7.76 → 1.8.1
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/dist/adapter.d.ts +5 -0
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +34 -4
- package/dist/adapter.js.map +1 -1
- package/dist/agent.d.ts +14 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +788 -36
- package/dist/agent.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +35 -0
- package/dist/app.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/proof-generator.d.ts.map +1 -1
- package/dist/proof-generator.js +12 -10
- package/dist/proof-generator.js.map +1 -1
- package/dist/providers/crypto.d.ts +1 -1
- package/dist/providers/crypto.d.ts.map +1 -1
- package/dist/providers/crypto.js +5 -1
- package/dist/providers/crypto.js.map +1 -1
- package/dist/services/consent.service.d.ts +18 -0
- package/dist/services/consent.service.d.ts.map +1 -1
- package/dist/services/consent.service.js +155 -39
- package/dist/services/consent.service.js.map +1 -1
- package/dist/services/vault-resolver.d.ts +55 -0
- package/dist/services/vault-resolver.d.ts.map +1 -0
- package/dist/services/vault-resolver.js +144 -0
- package/dist/services/vault-resolver.js.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +18 -9
package/dist/agent.js
CHANGED
|
@@ -24,8 +24,11 @@ import { OAuthSecurityService } from "./services/oauth-security.service";
|
|
|
24
24
|
import { WorkersFetchProvider } from "./providers/storage";
|
|
25
25
|
import { resolveClientIdentity, } from "./services/kta-client-lookup";
|
|
26
26
|
import { findKnownClient } from "./utils/known-clients";
|
|
27
|
+
import { didKeyFragment } from "@kya-os/mcp-i-core/utils/did-helpers";
|
|
27
28
|
import { calculateDOInstanceId, parseDORoutingStrategy, parseDOShardCount, } from "./utils/do-routing";
|
|
28
29
|
import { defaultProviderRegistry, } from "@kya-os/provider-registry";
|
|
30
|
+
import { hasApiKeyAuthorization, AuthRequiredError } from "@kya-os/contracts/tool-protection";
|
|
31
|
+
import { VaultResolver } from "./services/vault-resolver";
|
|
29
32
|
// CRITICAL: Force OAuth registry evaluation at module load time
|
|
30
33
|
// This creates an observable side effect that esbuild cannot tree-shake
|
|
31
34
|
// Without this, esbuild may eliminate oauth-service-registry.ts entirely
|
|
@@ -65,6 +68,8 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
65
68
|
_autoDetectedOrigin; // Auto-detected server origin from request URL
|
|
66
69
|
_clientMessagesConfig; // Client-specific delegation messages
|
|
67
70
|
_clientProvidedSessionId; // Deprecated: Use KV mapping instead to avoid race conditions
|
|
71
|
+
_consentUIResourceUri; // MCP Apps consent UI resource URI (set if ext-apps available)
|
|
72
|
+
_vaultResolver; // Lazy-init: per-user API key vault resolver (VAULT-9)
|
|
68
73
|
constructor(state, env, providerRegistry = defaultProviderRegistry) {
|
|
69
74
|
// Call super() with just state and env (agents@0.2.21+ only accepts 2 parameters)
|
|
70
75
|
// The config is no longer passed to the constructor - it's set via the server property
|
|
@@ -89,6 +94,11 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
89
94
|
mappedEnv._durableObjectState = state;
|
|
90
95
|
// Load runtime configuration from subclass
|
|
91
96
|
const runtimeConfig = this.getRuntimeConfigInternal(mappedEnv);
|
|
97
|
+
// Allow MCPI_INLINE_CONSENT env var to override the config flag
|
|
98
|
+
if (mappedEnv.MCPI_INLINE_CONSENT === "true" ||
|
|
99
|
+
mappedEnv.MCPI_INLINE_CONSENT === "1") {
|
|
100
|
+
runtimeConfig.inlineConsent = true;
|
|
101
|
+
}
|
|
92
102
|
// Store environment for logging checks
|
|
93
103
|
this._environment = runtimeConfig.environment || "production";
|
|
94
104
|
// Create tool protection service if configured
|
|
@@ -166,6 +176,16 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
166
176
|
const clientInfo = params?.clientInfo;
|
|
167
177
|
const protocolVersion = params?.protocolVersion;
|
|
168
178
|
const capabilities = params?.capabilities;
|
|
179
|
+
// Log raw capabilities from client for debugging ext-apps support
|
|
180
|
+
console.error("[MCPICloudflareAgent] Client initialize capabilities:", {
|
|
181
|
+
clientName: clientInfo?.name,
|
|
182
|
+
protocolVersion,
|
|
183
|
+
hasCapabilities: !!capabilities,
|
|
184
|
+
capabilityKeys: capabilities ? Object.keys(capabilities) : [],
|
|
185
|
+
hasExtensions: !!capabilities?.extensions,
|
|
186
|
+
extensionKeys: capabilities?.extensions ? Object.keys(capabilities.extensions) : [],
|
|
187
|
+
rawCapabilities: JSON.stringify(capabilities)?.substring(0, 500),
|
|
188
|
+
});
|
|
169
189
|
// Store client info for later retrieval (always, not just in dev)
|
|
170
190
|
if (clientInfo || protocolVersion) {
|
|
171
191
|
await this.ctx.storage.put("mcpClientInfo", {
|
|
@@ -247,7 +267,7 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
247
267
|
const rawKeyPair = await cryptoProvider.generateKeyPair();
|
|
248
268
|
// Generate proper did:key from Ed25519 public key
|
|
249
269
|
const userDid = generateDidKeyFromBase64(rawKeyPair.publicKey);
|
|
250
|
-
const keyId = `${userDid}
|
|
270
|
+
const keyId = `${userDid}#${didKeyFragment(userDid)}`;
|
|
251
271
|
const keyPair = {
|
|
252
272
|
did: userDid,
|
|
253
273
|
publicKey: rawKeyPair.publicKey,
|
|
@@ -500,6 +520,9 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
500
520
|
if (!this.server) {
|
|
501
521
|
throw new Error("Server not initialized. This should not happen - server is initialized in constructor.");
|
|
502
522
|
}
|
|
523
|
+
// Register MCP Apps consent UI resource BEFORE tools
|
|
524
|
+
// so that registerToolWithProof can add _meta.ui to tool definitions
|
|
525
|
+
await this.registerConsentUIResource();
|
|
503
526
|
// Register tools (implemented by subclasses)
|
|
504
527
|
await this.registerTools();
|
|
505
528
|
}
|
|
@@ -508,6 +531,492 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
508
531
|
throw error;
|
|
509
532
|
}
|
|
510
533
|
}
|
|
534
|
+
/**
|
|
535
|
+
* Register the MCP Apps consent UI resource.
|
|
536
|
+
*
|
|
537
|
+
* Uses the existing <mcp-consent> Lit components bundled into a
|
|
538
|
+
* self-contained HTML string. The resource is served via standard
|
|
539
|
+
* MCP resources/read -- hosts that support MCP Apps will render it
|
|
540
|
+
* in a sandboxed iframe when a tool requires delegation.
|
|
541
|
+
*
|
|
542
|
+
* Gracefully no-ops when @modelcontextprotocol/ext-apps or
|
|
543
|
+
* @kya-os/consent/mcp-app are not available.
|
|
544
|
+
*/
|
|
545
|
+
async registerConsentUIResource() {
|
|
546
|
+
try {
|
|
547
|
+
// Dynamic imports to handle optional peer dependencies
|
|
548
|
+
const [extApps, consentMcpApp, extAppsConstants] = await Promise.all([
|
|
549
|
+
import("@modelcontextprotocol/ext-apps/server").catch(() => null),
|
|
550
|
+
import("@kya-os/consent/mcp-app").catch(() => null),
|
|
551
|
+
import("@kya-os/mcp-i-core/runtime/ext-apps-constants").catch(() => null),
|
|
552
|
+
]);
|
|
553
|
+
if (!extApps || !consentMcpApp || !extAppsConstants) {
|
|
554
|
+
logger.info("[MCPICloudflareAgent] MCP Apps consent UI not available (missing optional dependencies)");
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const { registerAppResource, RESOURCE_MIME_TYPE } = extApps;
|
|
558
|
+
// consentMcpApp imported for future full Lit-based UI; inline HTML used for now
|
|
559
|
+
const { CONSENT_UI_RESOURCE_URI } = extAppsConstants;
|
|
560
|
+
// Determine server URL for CSP connect-src
|
|
561
|
+
const serverUrl = this.env.MCP_SERVER_URL ??
|
|
562
|
+
this._autoDetectedOrigin;
|
|
563
|
+
const connectDomains = ["*.kya.vouched.id"];
|
|
564
|
+
if (serverUrl) {
|
|
565
|
+
try {
|
|
566
|
+
connectDomains.push(new URL(serverUrl).hostname);
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
// Invalid URL, skip
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
registerAppResource(this.server, "MCPI Consent", CONSENT_UI_RESOURCE_URI, {
|
|
573
|
+
description: "Authorization consent form for protected tools",
|
|
574
|
+
_meta: {
|
|
575
|
+
ui: {
|
|
576
|
+
csp: {
|
|
577
|
+
connectDomains,
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
}, async () => {
|
|
582
|
+
// Use minimal inline handshake HTML while debugging LIT bundle issues.
|
|
583
|
+
// The full CONSENT_MCP_APP_HTML (628KB) loads but doesn't render —
|
|
584
|
+
// likely a CSP or sandboxed iframe restriction on the LIT bundle.
|
|
585
|
+
// This minimal version proves the data pipeline works end-to-end.
|
|
586
|
+
const html = `<!DOCTYPE html>
|
|
587
|
+
<html lang="en">
|
|
588
|
+
<head>
|
|
589
|
+
<meta charset="utf-8">
|
|
590
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
591
|
+
<meta name="color-scheme" content="light dark">
|
|
592
|
+
<title>MCPI Consent</title>
|
|
593
|
+
<style>
|
|
594
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
595
|
+
body { font-family: system-ui, -apple-system, sans-serif; padding: 16px; background: transparent; }
|
|
596
|
+
.card { border-radius: 12px; padding: 20px; border: 1px solid rgba(128,128,128,0.2); }
|
|
597
|
+
h2 { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
|
|
598
|
+
p { font-size: 13px; opacity: 0.7; line-height: 1.5; margin-bottom: 6px; }
|
|
599
|
+
.scopes { margin: 10px 0; }
|
|
600
|
+
.scope { display: inline-block; padding: 3px 10px; border-radius: 10px; font-size: 12px; font-weight: 500; margin: 2px 4px 2px 0; background: rgba(46,125,50,0.1); color: #2e7d32; }
|
|
601
|
+
.btn { display: inline-block; padding: 8px 20px; border-radius: 8px; border: none; font-size: 14px; font-weight: 500; cursor: pointer; margin-right: 8px; margin-top: 10px; }
|
|
602
|
+
.btn-approve { background: #2563eb; color: #fff; }
|
|
603
|
+
.btn-approve:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
604
|
+
.btn-deny { background: transparent; border: 1px solid rgba(128,128,128,0.3); }
|
|
605
|
+
.info { font-size: 11px; opacity: 0.5; margin-top: 12px; }
|
|
606
|
+
.fallback { font-size: 12px; margin-top: 8px; }
|
|
607
|
+
.fallback a { color: #2563eb; }
|
|
608
|
+
#loading { font-size: 13px; opacity: 0.6; }
|
|
609
|
+
#consent { display: none; }
|
|
610
|
+
#approved { display: none; font-size: 14px; color: #16a34a; font-weight: 500; }
|
|
611
|
+
.cred-fields { display: none; margin: 12px 0; }
|
|
612
|
+
.cred-fields label { display: block; font-size: 12px; font-weight: 500; margin-bottom: 4px; opacity: 0.8; }
|
|
613
|
+
.cred-fields input[type="text"],
|
|
614
|
+
.cred-fields input[type="password"] { width: 100%; padding: 8px 10px; border-radius: 6px; border: 1px solid rgba(128,128,128,0.3); font-size: 14px; margin-bottom: 10px; background: transparent; color: inherit; }
|
|
615
|
+
.cred-fields input:focus { outline: none; border-color: #2563eb; }
|
|
616
|
+
.pw-wrap { position: relative; }
|
|
617
|
+
.pw-toggle { position: absolute; right: 8px; top: 8px; background: none; border: none; cursor: pointer; font-size: 13px; opacity: 0.5; color: inherit; }
|
|
618
|
+
.terms-row { display: flex; align-items: flex-start; gap: 8px; margin: 10px 0 4px; }
|
|
619
|
+
.terms-row input[type="checkbox"] { margin-top: 2px; }
|
|
620
|
+
.terms-row label { font-size: 12px; opacity: 0.7; cursor: pointer; }
|
|
621
|
+
.auth-error { display: none; font-size: 13px; color: #dc2626; margin: 8px 0; padding: 8px 10px; border-radius: 6px; background: rgba(220,38,38,0.08); }
|
|
622
|
+
</style>
|
|
623
|
+
</head>
|
|
624
|
+
<body>
|
|
625
|
+
<div id="loading">Waiting for consent data…</div>
|
|
626
|
+
<div id="consent" class="card">
|
|
627
|
+
<h2>Authorization Required</h2>
|
|
628
|
+
<p id="tool-desc"></p>
|
|
629
|
+
<div id="scopes-container" class="scopes"></div>
|
|
630
|
+
<div id="cred-fields" class="cred-fields">
|
|
631
|
+
<label for="cred-username">Username / Email</label>
|
|
632
|
+
<input type="text" id="cred-username" autocomplete="username" placeholder="Enter your username or email">
|
|
633
|
+
<label for="cred-password">Password</label>
|
|
634
|
+
<div class="pw-wrap">
|
|
635
|
+
<input type="password" id="cred-password" autocomplete="current-password" placeholder="Enter your password">
|
|
636
|
+
<button type="button" class="pw-toggle" id="pw-toggle" aria-label="Toggle password visibility">Show</button>
|
|
637
|
+
</div>
|
|
638
|
+
</div>
|
|
639
|
+
<div id="auth-error" class="auth-error"></div>
|
|
640
|
+
<div id="terms-row" class="terms-row" style="display:none;">
|
|
641
|
+
<input type="checkbox" id="terms-check">
|
|
642
|
+
<label for="terms-check">I agree to authorize this tool and accept the terms of service</label>
|
|
643
|
+
</div>
|
|
644
|
+
<p id="agent-info" class="info"></p>
|
|
645
|
+
<div>
|
|
646
|
+
<button class="btn btn-approve" id="approve-btn">Approve</button>
|
|
647
|
+
<button class="btn btn-deny" id="deny-btn">Deny</button>
|
|
648
|
+
</div>
|
|
649
|
+
<div class="fallback" id="fallback"></div>
|
|
650
|
+
</div>
|
|
651
|
+
<div id="approved"></div>
|
|
652
|
+
<script>
|
|
653
|
+
(function() {
|
|
654
|
+
var send = function(msg) { window.parent.postMessage(msg, "*"); };
|
|
655
|
+
var consentData = null;
|
|
656
|
+
var authMode = "consent-only";
|
|
657
|
+
|
|
658
|
+
function resizeNotify() {
|
|
659
|
+
send({ jsonrpc: "2.0", method: "ui/notifications/size-changed", params: {
|
|
660
|
+
width: document.documentElement.scrollWidth,
|
|
661
|
+
height: document.documentElement.scrollHeight
|
|
662
|
+
}});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function showConsent(data) {
|
|
666
|
+
consentData = data;
|
|
667
|
+
authMode = data.authMode || "consent-only";
|
|
668
|
+
document.getElementById("loading").style.display = "none";
|
|
669
|
+
document.getElementById("consent").style.display = "block";
|
|
670
|
+
|
|
671
|
+
if (authMode === "credentials") {
|
|
672
|
+
document.getElementById("tool-desc").textContent =
|
|
673
|
+
"Tool \u201c" + (data.tool || "unknown") + "\u201d requires authentication.";
|
|
674
|
+
document.getElementById("cred-fields").style.display = "block";
|
|
675
|
+
document.getElementById("terms-row").style.display = "flex";
|
|
676
|
+
document.getElementById("approve-btn").textContent = "Sign In & Authorize";
|
|
677
|
+
document.getElementById("approve-btn").disabled = true;
|
|
678
|
+
document.getElementById("terms-check").addEventListener("change", function() {
|
|
679
|
+
document.getElementById("approve-btn").disabled = !this.checked;
|
|
680
|
+
});
|
|
681
|
+
} else {
|
|
682
|
+
document.getElementById("tool-desc").textContent =
|
|
683
|
+
"Tool \u201c" + (data.tool || "unknown") + "\u201d requires your approval.";
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
var scopesEl = document.getElementById("scopes-container");
|
|
687
|
+
var scopes = data.scopes || [];
|
|
688
|
+
scopesEl.textContent = "";
|
|
689
|
+
scopes.forEach(function(s) {
|
|
690
|
+
var span = document.createElement("span");
|
|
691
|
+
span.className = "scope";
|
|
692
|
+
span.textContent = s;
|
|
693
|
+
scopesEl.appendChild(span);
|
|
694
|
+
});
|
|
695
|
+
document.getElementById("agent-info").textContent =
|
|
696
|
+
"Agent: " + (data.agentName || data.agentDid || "unknown");
|
|
697
|
+
if (data.consentUrl) {
|
|
698
|
+
var fallbackEl = document.getElementById("fallback");
|
|
699
|
+
fallbackEl.textContent = "";
|
|
700
|
+
var a = document.createElement("a");
|
|
701
|
+
a.href = data.consentUrl;
|
|
702
|
+
a.target = "_blank";
|
|
703
|
+
a.rel = "noopener";
|
|
704
|
+
a.textContent = "Open in browser";
|
|
705
|
+
fallbackEl.appendChild(a);
|
|
706
|
+
}
|
|
707
|
+
resizeNotify();
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Password visibility toggle
|
|
711
|
+
document.getElementById("pw-toggle").addEventListener("click", function() {
|
|
712
|
+
var pwInput = document.getElementById("cred-password");
|
|
713
|
+
if (pwInput.type === "password") {
|
|
714
|
+
pwInput.type = "text";
|
|
715
|
+
this.textContent = "Hide";
|
|
716
|
+
} else {
|
|
717
|
+
pwInput.type = "password";
|
|
718
|
+
this.textContent = "Show";
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
var pendingRequests = {};
|
|
723
|
+
var nextId = 100;
|
|
724
|
+
|
|
725
|
+
function handleApprovalResponse(result, error) {
|
|
726
|
+
var statusEl = document.getElementById("approved");
|
|
727
|
+
if (error) {
|
|
728
|
+
// Show error inline for credential mode (allow retry)
|
|
729
|
+
if (authMode === "credentials") {
|
|
730
|
+
var errEl = document.getElementById("auth-error");
|
|
731
|
+
errEl.textContent = error.message || JSON.stringify(error);
|
|
732
|
+
errEl.style.display = "block";
|
|
733
|
+
document.getElementById("consent").style.display = "block";
|
|
734
|
+
statusEl.style.display = "none";
|
|
735
|
+
document.getElementById("approve-btn").disabled = !document.getElementById("terms-check").checked;
|
|
736
|
+
resizeNotify();
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
statusEl.textContent = "Approval error: " + (error.message || JSON.stringify(error));
|
|
740
|
+
statusEl.style.color = "#dc2626";
|
|
741
|
+
} else {
|
|
742
|
+
try {
|
|
743
|
+
var text = result && result.content && result.content[0] && result.content[0].text;
|
|
744
|
+
var respData = text ? JSON.parse(text) : {};
|
|
745
|
+
if (respData.success) {
|
|
746
|
+
statusEl.textContent = "Authorization granted. You can now retry the tool.";
|
|
747
|
+
statusEl.style.color = "#22c55e";
|
|
748
|
+
} else {
|
|
749
|
+
// For credential auth failures, show error inline and allow retry
|
|
750
|
+
if (authMode === "credentials" && (respData.error_code === "auth_failed" || respData.error_code === "validation_error")) {
|
|
751
|
+
var errEl = document.getElementById("auth-error");
|
|
752
|
+
errEl.textContent = respData.error || "Authentication failed. Please check your credentials.";
|
|
753
|
+
errEl.style.display = "block";
|
|
754
|
+
document.getElementById("consent").style.display = "block";
|
|
755
|
+
statusEl.style.display = "none";
|
|
756
|
+
document.getElementById("approve-btn").disabled = !document.getElementById("terms-check").checked;
|
|
757
|
+
resizeNotify();
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
statusEl.textContent = "Approval error: " + (respData.error || respData.error_code || "Unknown");
|
|
761
|
+
statusEl.style.color = "#f59e0b";
|
|
762
|
+
}
|
|
763
|
+
} catch(e) {
|
|
764
|
+
statusEl.textContent = "Unexpected response";
|
|
765
|
+
statusEl.style.color = "#f59e0b";
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
resizeNotify();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
document.getElementById("approve-btn").addEventListener("click", function() {
|
|
772
|
+
if (!consentData) return;
|
|
773
|
+
// Hide any previous error
|
|
774
|
+
document.getElementById("auth-error").style.display = "none";
|
|
775
|
+
var statusEl = document.getElementById("approved");
|
|
776
|
+
document.getElementById("consent").style.display = "none";
|
|
777
|
+
statusEl.style.display = "block";
|
|
778
|
+
statusEl.textContent = authMode === "credentials" ? "Authenticating..." : "Sending approval...";
|
|
779
|
+
statusEl.style.color = "";
|
|
780
|
+
// Use tools/call via postMessage (proxied through host to MCP server)
|
|
781
|
+
// instead of fetch() which is blocked in the sandboxed iframe.
|
|
782
|
+
var reqId = nextId++;
|
|
783
|
+
pendingRequests[reqId] = handleApprovalResponse;
|
|
784
|
+
|
|
785
|
+
if (authMode === "credentials") {
|
|
786
|
+
var username = document.getElementById("cred-username").value;
|
|
787
|
+
var password = document.getElementById("cred-password").value;
|
|
788
|
+
if (!username || !password) {
|
|
789
|
+
statusEl.style.display = "none";
|
|
790
|
+
document.getElementById("consent").style.display = "block";
|
|
791
|
+
var errEl = document.getElementById("auth-error");
|
|
792
|
+
errEl.textContent = "Please enter both username and password.";
|
|
793
|
+
errEl.style.display = "block";
|
|
794
|
+
resizeNotify();
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
send({
|
|
798
|
+
jsonrpc: "2.0", id: reqId, method: "tools/call",
|
|
799
|
+
params: {
|
|
800
|
+
name: "_mcpi_credential_auth",
|
|
801
|
+
arguments: {
|
|
802
|
+
tool: consentData.tool,
|
|
803
|
+
scopes: consentData.scopes,
|
|
804
|
+
session_id: consentData.sessionId,
|
|
805
|
+
agent_did: consentData.agentDid,
|
|
806
|
+
project_id: consentData.projectId,
|
|
807
|
+
resume_token: consentData.resumeToken,
|
|
808
|
+
username: username,
|
|
809
|
+
password: password,
|
|
810
|
+
provider: consentData.provider || "credentials"
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
} else {
|
|
815
|
+
send({
|
|
816
|
+
jsonrpc: "2.0", id: reqId, method: "tools/call",
|
|
817
|
+
params: {
|
|
818
|
+
name: "_mcpi_approve_consent",
|
|
819
|
+
arguments: {
|
|
820
|
+
tool: consentData.tool,
|
|
821
|
+
scopes: consentData.scopes,
|
|
822
|
+
session_id: consentData.sessionId,
|
|
823
|
+
agent_did: consentData.agentDid,
|
|
824
|
+
project_id: consentData.projectId,
|
|
825
|
+
resume_token: consentData.resumeToken
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
resizeNotify();
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
document.getElementById("deny-btn").addEventListener("click", function() {
|
|
834
|
+
document.getElementById("consent").style.display = "none";
|
|
835
|
+
document.getElementById("approved").style.display = "block";
|
|
836
|
+
document.getElementById("approved").textContent = "Authorization denied.";
|
|
837
|
+
document.getElementById("approved").style.color = "#dc2626";
|
|
838
|
+
resizeNotify();
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
window.addEventListener("message", function(e) {
|
|
842
|
+
if (e.source !== window.parent) return;
|
|
843
|
+
var msg = e.data;
|
|
844
|
+
if (!msg || msg.jsonrpc !== "2.0") return;
|
|
845
|
+
|
|
846
|
+
if (msg.id === 0 && msg.result) {
|
|
847
|
+
var ctx = msg.result.hostContext || {};
|
|
848
|
+
if (ctx.theme === "dark") document.documentElement.style.colorScheme = "dark";
|
|
849
|
+
send({ jsonrpc: "2.0", method: "ui/notifications/initialized" });
|
|
850
|
+
resizeNotify();
|
|
851
|
+
}
|
|
852
|
+
// Handle responses to our pending tools/call requests
|
|
853
|
+
if (msg.id != null && pendingRequests[msg.id]) {
|
|
854
|
+
pendingRequests[msg.id](msg.result, msg.error);
|
|
855
|
+
delete pendingRequests[msg.id];
|
|
856
|
+
}
|
|
857
|
+
if (msg.method === "ui/notifications/tool-result" && msg.params) {
|
|
858
|
+
var sc = msg.params.structuredContent;
|
|
859
|
+
if (sc && sc.type === "consent_required") showConsent(sc);
|
|
860
|
+
}
|
|
861
|
+
if (msg.method === "ui/notifications/tool-input" && msg.params) {
|
|
862
|
+
var args = msg.params.arguments;
|
|
863
|
+
if (args && args.type === "consent_required") showConsent(args);
|
|
864
|
+
}
|
|
865
|
+
if (msg.method === "ping" && msg.id != null) {
|
|
866
|
+
send({ jsonrpc: "2.0", id: msg.id, result: {} });
|
|
867
|
+
}
|
|
868
|
+
if (msg.method === "ui/resource-teardown" && msg.id != null) {
|
|
869
|
+
send({ jsonrpc: "2.0", id: msg.id, result: {} });
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
send({
|
|
874
|
+
jsonrpc: "2.0", id: 0, method: "ui/initialize",
|
|
875
|
+
params: {
|
|
876
|
+
appInfo: { name: "MCPI Consent", version: "1.0.0" },
|
|
877
|
+
appCapabilities: {},
|
|
878
|
+
protocolVersion: "2026-01-26"
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
})();
|
|
882
|
+
</script>
|
|
883
|
+
</body>
|
|
884
|
+
</html>`;
|
|
885
|
+
return {
|
|
886
|
+
contents: [
|
|
887
|
+
{
|
|
888
|
+
uri: CONSENT_UI_RESOURCE_URI,
|
|
889
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
890
|
+
text: html,
|
|
891
|
+
},
|
|
892
|
+
],
|
|
893
|
+
};
|
|
894
|
+
});
|
|
895
|
+
// Store the resource URI so registerToolWithProof can add _meta.ui to tool definitions
|
|
896
|
+
this._consentUIResourceUri = CONSENT_UI_RESOURCE_URI;
|
|
897
|
+
// Signal to MCPIRuntimeBase that ext-apps is available.
|
|
898
|
+
// The base class's isExtAppsAvailable() uses synchronous require() which
|
|
899
|
+
// fails for ESM-only packages in CJS/Workers contexts. This cached flag
|
|
900
|
+
// ensures buildConsentUIResult() works correctly at tool-call time.
|
|
901
|
+
this.mcpiRuntime?.setExtAppsAvailable(true);
|
|
902
|
+
// Register internal tool for iframe-based consent approval.
|
|
903
|
+
// The sandboxed MCP Apps iframe cannot call fetch() directly, so it
|
|
904
|
+
// uses tools/call (proxied via postMessage through the host) to invoke
|
|
905
|
+
// this tool which handles the /consent/approve logic server-side.
|
|
906
|
+
this.server.tool("_mcpi_approve_consent", "DO NOT call this tool directly. Internal system tool used by the consent UI iframe to process approval actions. Only the embedded consent form should invoke this.", {
|
|
907
|
+
tool: z.string(),
|
|
908
|
+
scopes: z.array(z.string()),
|
|
909
|
+
session_id: z.string(),
|
|
910
|
+
agent_did: z.string(),
|
|
911
|
+
project_id: z.string(),
|
|
912
|
+
resume_token: z.string(),
|
|
913
|
+
}, async (args) => {
|
|
914
|
+
const envPrefix = this.getEnvPrefix();
|
|
915
|
+
const mappedEnv = envPrefix
|
|
916
|
+
? mapPrefixedEnv(this.env, envPrefix)
|
|
917
|
+
: this.env;
|
|
918
|
+
const serverUrl = mappedEnv.MCP_SERVER_URL ??
|
|
919
|
+
this._autoDetectedOrigin;
|
|
920
|
+
if (!serverUrl) {
|
|
921
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "No server URL configured" }) }] };
|
|
922
|
+
}
|
|
923
|
+
try {
|
|
924
|
+
const resp = await fetch(`${serverUrl}/consent/approve`, {
|
|
925
|
+
method: "POST",
|
|
926
|
+
headers: { "Content-Type": "application/json" },
|
|
927
|
+
body: JSON.stringify({
|
|
928
|
+
tool: args.tool,
|
|
929
|
+
scopes: args.scopes,
|
|
930
|
+
session_id: args.session_id,
|
|
931
|
+
agent_did: args.agent_did,
|
|
932
|
+
project_id: args.project_id,
|
|
933
|
+
resume_token: args.resume_token,
|
|
934
|
+
termsAccepted: true,
|
|
935
|
+
approved: true,
|
|
936
|
+
}),
|
|
937
|
+
});
|
|
938
|
+
if (!resp.ok) {
|
|
939
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Server error: ${resp.status}` }) }] };
|
|
940
|
+
}
|
|
941
|
+
const data = await resp.json();
|
|
942
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
943
|
+
}
|
|
944
|
+
catch (err) {
|
|
945
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
946
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: false, error: msg }) }] };
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
// Register internal tool for iframe-based credential authentication.
|
|
950
|
+
// Same sandbox workaround as _mcpi_approve_consent: the iframe calls
|
|
951
|
+
// tools/call via postMessage, which the host proxies to this tool.
|
|
952
|
+
// This tool does credential auth + delegation in a single step.
|
|
953
|
+
this.server.tool("_mcpi_credential_auth", "DO NOT call this tool directly. Internal system tool used by the consent UI iframe to process credential authentication. Only the embedded consent form should invoke this.", {
|
|
954
|
+
tool: z.string(),
|
|
955
|
+
scopes: z.array(z.string()),
|
|
956
|
+
session_id: z.string(),
|
|
957
|
+
agent_did: z.string(),
|
|
958
|
+
project_id: z.string(),
|
|
959
|
+
resume_token: z.string(),
|
|
960
|
+
username: z.string(),
|
|
961
|
+
password: z.string(),
|
|
962
|
+
provider: z.string(),
|
|
963
|
+
}, async (args) => {
|
|
964
|
+
const envPrefix = this.getEnvPrefix();
|
|
965
|
+
const mappedEnv = envPrefix
|
|
966
|
+
? mapPrefixedEnv(this.env, envPrefix)
|
|
967
|
+
: this.env;
|
|
968
|
+
const serverUrl = mappedEnv.MCP_SERVER_URL ??
|
|
969
|
+
this._autoDetectedOrigin;
|
|
970
|
+
if (!serverUrl) {
|
|
971
|
+
return {
|
|
972
|
+
content: [
|
|
973
|
+
{
|
|
974
|
+
type: "text",
|
|
975
|
+
text: JSON.stringify({
|
|
976
|
+
success: false,
|
|
977
|
+
error: "No server URL configured",
|
|
978
|
+
}),
|
|
979
|
+
},
|
|
980
|
+
],
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
try {
|
|
984
|
+
const resp = await fetch(`${serverUrl}/consent/approve`, {
|
|
985
|
+
method: "POST",
|
|
986
|
+
headers: { "Content-Type": "application/json" },
|
|
987
|
+
body: JSON.stringify({
|
|
988
|
+
tool: args.tool,
|
|
989
|
+
scopes: args.scopes,
|
|
990
|
+
session_id: args.session_id,
|
|
991
|
+
agent_did: args.agent_did,
|
|
992
|
+
project_id: args.project_id,
|
|
993
|
+
provider_type: "password",
|
|
994
|
+
provider: args.provider,
|
|
995
|
+
username: args.username,
|
|
996
|
+
password: args.password,
|
|
997
|
+
inline_mode: true,
|
|
998
|
+
termsAccepted: true,
|
|
999
|
+
approved: true,
|
|
1000
|
+
}),
|
|
1001
|
+
});
|
|
1002
|
+
if (!resp.ok) {
|
|
1003
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Server error: ${resp.status}` }) }] };
|
|
1004
|
+
}
|
|
1005
|
+
const data = await resp.json();
|
|
1006
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
1007
|
+
}
|
|
1008
|
+
catch (err) {
|
|
1009
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
1010
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: false, error: msg }) }] };
|
|
1011
|
+
}
|
|
1012
|
+
});
|
|
1013
|
+
logger.info("[MCPICloudflareAgent] Registered MCP Apps consent UI resource");
|
|
1014
|
+
}
|
|
1015
|
+
catch {
|
|
1016
|
+
// ext-apps or consent/mcp-app not available - graceful no-op
|
|
1017
|
+
logger.info("[MCPICloudflareAgent] MCP Apps consent UI not available (ext-apps or consent/mcp-app not installed)");
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
511
1020
|
/**
|
|
512
1021
|
* Register a tool with automatic session ID extraction and proof generation
|
|
513
1022
|
*
|
|
@@ -602,14 +1111,32 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
602
1111
|
zodSchemaShape = schema;
|
|
603
1112
|
}
|
|
604
1113
|
}
|
|
605
|
-
|
|
1114
|
+
// Use registerTool (not deprecated server.tool) to support _meta.ui for MCP Apps
|
|
1115
|
+
const toolCallback = async (args, extra) => {
|
|
606
1116
|
// ✅ Automatically extract sessionId from ToolExtraArguments
|
|
607
1117
|
// This ensures delegation tokens stored with the original session ID
|
|
608
1118
|
// can be retrieved on subsequent tool calls
|
|
609
|
-
// Note: extra is typed as 'any' to match MCP SDK's ToolExtraArguments type
|
|
610
1119
|
const sessionId = extra?.sessionId;
|
|
611
1120
|
return this.executeToolWithProof(name, args, handler, sessionId);
|
|
612
|
-
}
|
|
1121
|
+
};
|
|
1122
|
+
// Build tool config with optional MCP Apps UI metadata
|
|
1123
|
+
// When ext-apps consent UI is registered, include _meta.ui.resourceUri
|
|
1124
|
+
// so clients know this tool can render inline consent UI
|
|
1125
|
+
const toolConfig = {
|
|
1126
|
+
description,
|
|
1127
|
+
inputSchema: zodSchemaShape,
|
|
1128
|
+
};
|
|
1129
|
+
if (this._consentUIResourceUri) {
|
|
1130
|
+
// Include BOTH new nested format AND legacy flat format for maximum client compatibility
|
|
1131
|
+
// registerAppTool() from ext-apps SDK normalizes both; we replicate that here
|
|
1132
|
+
// New format: _meta.ui.resourceUri (preferred)
|
|
1133
|
+
// Legacy format: _meta["ui/resourceUri"] (deprecated but some clients may check this)
|
|
1134
|
+
toolConfig._meta = {
|
|
1135
|
+
ui: { resourceUri: this._consentUIResourceUri },
|
|
1136
|
+
"ui/resourceUri": this._consentUIResourceUri,
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
this.server.registerTool(name, toolConfig, toolCallback);
|
|
613
1140
|
}
|
|
614
1141
|
catch (error) {
|
|
615
1142
|
logger.error(`[MCPICloudflareAgent] Failed to register tool "${name}":`, error);
|
|
@@ -857,6 +1384,7 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
857
1384
|
createdAt: timestamp,
|
|
858
1385
|
expiresAt: timestamp + 30 * 60 * 1000, // 30 minutes
|
|
859
1386
|
serverOrigin: serverUrl, // Use MCP_SERVER_URL for consent URL building
|
|
1387
|
+
projectId: mappedEnv.AGENTSHIELD_PROJECT_ID || "", // For inline consent UI approve POST
|
|
860
1388
|
delegationToken, // ✅ Include token in session if found
|
|
861
1389
|
delegationId, // ✅ Include delegationId for proof submission attribution
|
|
862
1390
|
userDid, // ✅ FIX: Include userDid in session for ToolExecutionContext propagation
|
|
@@ -883,6 +1411,10 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
883
1411
|
// ✅ SECURITY FIX: Store original auth error to re-throw if URL building fails
|
|
884
1412
|
// This prevents unauthorized tool execution when URL building fails
|
|
885
1413
|
let originalAuthError = null;
|
|
1414
|
+
// ✅ MCP Apps inline UI result for credential auth (returns UI instead of throwing)
|
|
1415
|
+
let pendingInlineResult = null;
|
|
1416
|
+
// Store AuthRequiredError to throw inside the inner try block where its handler lives
|
|
1417
|
+
let pendingAuthRequiredError = null;
|
|
886
1418
|
try {
|
|
887
1419
|
toolContext = await this.buildToolContext(toolName, userDid, sessionId, delegationToken, mappedEnv);
|
|
888
1420
|
}
|
|
@@ -901,17 +1433,31 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
901
1433
|
const scopes = credError.toolProtection?.requiredScopes || [
|
|
902
1434
|
`${toolName}:execute`,
|
|
903
1435
|
];
|
|
904
|
-
// Build consent page URL with credentials mode
|
|
905
|
-
const consentUrl = await this.buildConsentUrlForCredentials(toolName, provider, scopes, sessionId, mappedEnv);
|
|
906
1436
|
const resumeToken = `resume_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1437
|
+
// Try MCP Apps inline credential UI first (preferred path)
|
|
1438
|
+
// Note: clientCapabilities not available in this context — pass undefined.
|
|
1439
|
+
// buildConsentUIResult gates on isExtAppsAvailable(), not client caps.
|
|
1440
|
+
const inlineResult = this.mcpiRuntime.buildConsentUIResult(toolName, scopes, session, resumeToken, session.projectId, session.serverOrigin, undefined, "credentials", provider);
|
|
1441
|
+
if (inlineResult) {
|
|
1442
|
+
logger.info("[MCPICloudflareAgent] 🔑 Credential auth required (inline UI):", {
|
|
1443
|
+
tool: toolName,
|
|
1444
|
+
provider,
|
|
1445
|
+
scopes,
|
|
1446
|
+
});
|
|
1447
|
+
pendingInlineResult = inlineResult;
|
|
1448
|
+
}
|
|
1449
|
+
else {
|
|
1450
|
+
// ext-apps not available — fall back to external consent URL
|
|
1451
|
+
const consentUrl = await this.buildConsentUrlForCredentials(toolName, provider, scopes, sessionId, mappedEnv);
|
|
1452
|
+
logger.info("[MCPICloudflareAgent] 🔑 Credential auth required:", {
|
|
1453
|
+
tool: toolName,
|
|
1454
|
+
provider,
|
|
1455
|
+
scopes,
|
|
1456
|
+
consentUrl: consentUrl.substring(0, 80) + "...",
|
|
1457
|
+
});
|
|
1458
|
+
// ✅ FIX: Store error instead of throwing to ensure proper formatting
|
|
1459
|
+
pendingAuthError = new DelegationRequiredError(toolName, scopes, consentUrl, undefined, resumeToken);
|
|
1460
|
+
}
|
|
915
1461
|
}
|
|
916
1462
|
catch (credentialError) {
|
|
917
1463
|
// If URL building fails, log the error but continue
|
|
@@ -982,25 +1528,37 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
982
1528
|
isCredentialProvider,
|
|
983
1529
|
});
|
|
984
1530
|
let authUrl;
|
|
1531
|
+
const resumeToken = `resume_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
985
1532
|
if (isCredentialProvider) {
|
|
986
|
-
//
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1533
|
+
// Try MCP Apps inline credential UI first
|
|
1534
|
+
const inlineResult = this.mcpiRuntime?.buildConsentUIResult(oauthError.toolName, oauthError.requiredScopes, session, resumeToken, session.projectId, session.serverOrigin, undefined, "credentials", oauthError.provider || "credentials");
|
|
1535
|
+
if (inlineResult) {
|
|
1536
|
+
logger.info("[MCPICloudflareAgent] 🔑 Credential auth required (inline UI from OAuthRequiredError):", {
|
|
1537
|
+
tool: oauthError.toolName,
|
|
1538
|
+
provider: oauthError.provider,
|
|
1539
|
+
scopes: oauthError.requiredScopes,
|
|
1540
|
+
});
|
|
1541
|
+
pendingInlineResult = inlineResult;
|
|
1542
|
+
}
|
|
1543
|
+
else {
|
|
1544
|
+
// ext-apps not available — fall back to external consent URL
|
|
1545
|
+
logger.info("[MCPICloudflareAgent] 🔑 Credential auth required (from OAuthRequiredError):", {
|
|
1546
|
+
tool: oauthError.toolName,
|
|
1547
|
+
provider: oauthError.provider,
|
|
1548
|
+
scopes: oauthError.requiredScopes,
|
|
1549
|
+
});
|
|
1550
|
+
authUrl = await this.buildConsentUrlForCredentials(oauthError.toolName, oauthError.provider || "credentials", oauthError.requiredScopes, sessionId, mappedEnv);
|
|
1551
|
+
}
|
|
993
1552
|
}
|
|
994
1553
|
else {
|
|
995
1554
|
// For OAuth providers (github, google, etc.), build OAuth URL
|
|
996
1555
|
authUrl = await this.buildOAuthUrlForError(oauthError, sessionId, mappedEnv);
|
|
997
1556
|
}
|
|
998
|
-
//
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
resumeToken);
|
|
1557
|
+
// Only create DelegationRequiredError if we didn't get an inline result
|
|
1558
|
+
if (!pendingInlineResult) {
|
|
1559
|
+
pendingAuthError = new DelegationRequiredError(oauthError.toolName, oauthError.requiredScopes, authUrl, undefined, // interceptedCall
|
|
1560
|
+
resumeToken);
|
|
1561
|
+
}
|
|
1004
1562
|
}
|
|
1005
1563
|
catch (oauthError) {
|
|
1006
1564
|
// If URL building fails, log the error
|
|
@@ -1012,10 +1570,18 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
1012
1570
|
}
|
|
1013
1571
|
}
|
|
1014
1572
|
}
|
|
1573
|
+
// Store AuthRequiredError to throw inside the inner try block
|
|
1574
|
+
// where the handler can catch it and return a structured MCP response.
|
|
1575
|
+
if (error instanceof Error &&
|
|
1576
|
+
(error.name === "AuthRequiredError" ||
|
|
1577
|
+
error.constructor?.name === "AuthRequiredError")) {
|
|
1578
|
+
pendingAuthRequiredError = error;
|
|
1579
|
+
}
|
|
1015
1580
|
// ✅ SECURITY FIX: If we had an auth error but URL building failed,
|
|
1016
1581
|
// we MUST re-throw the original error to prevent unauthorized tool execution.
|
|
1017
1582
|
// Only log and continue for non-auth errors (backward compatibility).
|
|
1018
|
-
|
|
1583
|
+
// Note: pendingInlineResult means auth is handled via inline UI — skip re-throw.
|
|
1584
|
+
if (!pendingAuthError && !pendingInlineResult && !pendingAuthRequiredError) {
|
|
1019
1585
|
if (originalAuthError) {
|
|
1020
1586
|
// Auth was required but URL building failed - MUST block execution
|
|
1021
1587
|
logger.error("[MCPICloudflareAgent] SECURITY: Auth required but URL building failed, blocking tool execution:", { originalError: originalAuthError.message, buildError: error });
|
|
@@ -1050,13 +1616,20 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
1050
1616
|
}
|
|
1051
1617
|
};
|
|
1052
1618
|
// Execute tool with automatic proof generation
|
|
1619
|
+
// If inline credential UI was prepared, return it directly (no error throw needed)
|
|
1620
|
+
if (pendingInlineResult) {
|
|
1621
|
+
return pendingInlineResult;
|
|
1622
|
+
}
|
|
1053
1623
|
// Wrap in try-catch to handle DelegationRequiredError and format for MCP clients
|
|
1054
1624
|
try {
|
|
1055
|
-
// ✅ FIX: Throw
|
|
1056
|
-
// This ensures
|
|
1625
|
+
// ✅ FIX: Throw pending errors INSIDE the try block
|
|
1626
|
+
// This ensures they're caught by the formatters below
|
|
1057
1627
|
if (pendingAuthError) {
|
|
1058
1628
|
throw pendingAuthError;
|
|
1059
1629
|
}
|
|
1630
|
+
if (pendingAuthRequiredError) {
|
|
1631
|
+
throw pendingAuthRequiredError;
|
|
1632
|
+
}
|
|
1060
1633
|
const result = await this.mcpiRuntime.processToolCall(toolName, args, handlerWithContext, session);
|
|
1061
1634
|
// NOTE: Proof submission is now awaited directly in CloudflareRuntime.processToolCall()
|
|
1062
1635
|
// This ensures reliable submission without relying on ctx.waitUntil() which is unreliable
|
|
@@ -1088,14 +1661,18 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
1088
1661
|
// Use DelegationErrorFormatter for consistent message formatting
|
|
1089
1662
|
// Supports client-specific messages via getClientMessagesConfig()
|
|
1090
1663
|
const formatter = new DelegationErrorFormatter(this.getClientMessagesConfig());
|
|
1091
|
-
const { message: displayMessage } = formatter.format({
|
|
1664
|
+
const { message: displayMessage, consentUrl } = formatter.format({
|
|
1092
1665
|
toolName: toolNameForError,
|
|
1093
1666
|
consentUrl: delegationError.consentUrl,
|
|
1094
1667
|
scopes,
|
|
1095
1668
|
clientId,
|
|
1096
1669
|
});
|
|
1097
|
-
// Return tool result with isError: true
|
|
1098
|
-
//
|
|
1670
|
+
// Return tool result with isError: true so MCP clients (Claude Desktop, Inspector)
|
|
1671
|
+
// surface the consent URL to users and trigger browser popup for authorization.
|
|
1672
|
+
// TODO(ext-apps): When MCP-UI / ext-apps support lands, make this dynamic:
|
|
1673
|
+
// - isError: false when the client supports inline consent UI (MCP Apps extension)
|
|
1674
|
+
// - isError: true as fallback for standard MCP clients (Claude Desktop, etc.)
|
|
1675
|
+
// See PR #274 for the inline consent UI implementation.
|
|
1099
1676
|
return {
|
|
1100
1677
|
content: [
|
|
1101
1678
|
{
|
|
@@ -1104,6 +1681,14 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
1104
1681
|
},
|
|
1105
1682
|
],
|
|
1106
1683
|
isError: true,
|
|
1684
|
+
_meta: {
|
|
1685
|
+
errorType: "authorization_required",
|
|
1686
|
+
toolName: toolNameForError,
|
|
1687
|
+
requiredScopes: scopes,
|
|
1688
|
+
consentUrl,
|
|
1689
|
+
authorizationUrl: consentUrl,
|
|
1690
|
+
resumeToken,
|
|
1691
|
+
},
|
|
1107
1692
|
};
|
|
1108
1693
|
}
|
|
1109
1694
|
// Handle OAuthRequiredError - use formatOAuth() to preserve provider name
|
|
@@ -1131,7 +1716,7 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
1131
1716
|
// Use DelegationErrorFormatter.formatOAuth() for OAuth-specific message
|
|
1132
1717
|
// This preserves the provider name (e.g., "Sign in with GitHub")
|
|
1133
1718
|
const formatter = new DelegationErrorFormatter(this.getClientMessagesConfig());
|
|
1134
|
-
const { message: displayMessage } = formatter.formatOAuth({
|
|
1719
|
+
const { message: displayMessage, oauthUrl } = formatter.formatOAuth({
|
|
1135
1720
|
toolName: toolNameForError,
|
|
1136
1721
|
oauthUrl: oauthError.oauthUrl,
|
|
1137
1722
|
provider,
|
|
@@ -1146,6 +1731,43 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
1146
1731
|
},
|
|
1147
1732
|
],
|
|
1148
1733
|
isError: true,
|
|
1734
|
+
_meta: {
|
|
1735
|
+
errorType: "oauth_required",
|
|
1736
|
+
toolName: toolNameForError,
|
|
1737
|
+
provider,
|
|
1738
|
+
requiredScopes: scopes,
|
|
1739
|
+
oauthUrl,
|
|
1740
|
+
authorizationUrl: oauthUrl,
|
|
1741
|
+
resumeToken: oauthError.resumeToken,
|
|
1742
|
+
},
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
// Handle AuthRequiredError - return machine-readable error with configUrl
|
|
1746
|
+
// MCP clients use this to show "Add your API key" with a link to the dashboard
|
|
1747
|
+
if (error instanceof Error &&
|
|
1748
|
+
(error.name === "AuthRequiredError" ||
|
|
1749
|
+
error.constructor?.name === "AuthRequiredError")) {
|
|
1750
|
+
const authError = error;
|
|
1751
|
+
logger.info("[MCPICloudflareAgent] 🔑 Auth required (no vault + no static env):", {
|
|
1752
|
+
tool: toolName,
|
|
1753
|
+
type: authError.type,
|
|
1754
|
+
envVarNames: authError.envVarNames,
|
|
1755
|
+
configUrl: authError.configUrl,
|
|
1756
|
+
});
|
|
1757
|
+
// Return both human-readable text AND machine-readable JSON
|
|
1758
|
+
// so MCP clients can parse the structured error or display the message
|
|
1759
|
+
return {
|
|
1760
|
+
content: [
|
|
1761
|
+
{
|
|
1762
|
+
type: "text",
|
|
1763
|
+
text: authError.message,
|
|
1764
|
+
},
|
|
1765
|
+
{
|
|
1766
|
+
type: "text",
|
|
1767
|
+
text: JSON.stringify(authError.toJSON()),
|
|
1768
|
+
},
|
|
1769
|
+
],
|
|
1770
|
+
isError: true,
|
|
1149
1771
|
};
|
|
1150
1772
|
}
|
|
1151
1773
|
// Re-throw other errors to be handled by MCP SDK
|
|
@@ -1158,8 +1780,9 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
1158
1780
|
* @private
|
|
1159
1781
|
*/
|
|
1160
1782
|
async buildToolContext(toolName, userDid, sessionId, delegationToken, env) {
|
|
1161
|
-
// Only build context if userDid is available and
|
|
1162
|
-
|
|
1783
|
+
// Only build context if userDid is available and AgentShield API is configured.
|
|
1784
|
+
// Note: DELEGATION_STORAGE is only required for password/OAuth paths, not apikey vault.
|
|
1785
|
+
if (!userDid || !env.AGENTSHIELD_API_KEY) {
|
|
1163
1786
|
return undefined;
|
|
1164
1787
|
}
|
|
1165
1788
|
// Check if tool protection service is available
|
|
@@ -1182,6 +1805,10 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
1182
1805
|
const isPasswordAuth = protection.authorization?.type === "password" ||
|
|
1183
1806
|
protection.oauthProvider === "credentials"; // Deprecated fallback
|
|
1184
1807
|
if (isPasswordAuth) {
|
|
1808
|
+
if (!env.DELEGATION_STORAGE) {
|
|
1809
|
+
logger.warn("[MCPICloudflareAgent] Password auth requires DELEGATION_STORAGE but it is not configured");
|
|
1810
|
+
return undefined;
|
|
1811
|
+
}
|
|
1185
1812
|
const provider = protection.authorization?.type === "password"
|
|
1186
1813
|
? protection.authorization.provider
|
|
1187
1814
|
: "credentials";
|
|
@@ -1302,6 +1929,110 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
1302
1929
|
return undefined;
|
|
1303
1930
|
}
|
|
1304
1931
|
}
|
|
1932
|
+
// For API key vault tools, resolve per-user secrets from AgentShield vault.
|
|
1933
|
+
// This follows the same early-return pattern as isPasswordAuth above.
|
|
1934
|
+
if (hasApiKeyAuthorization(protection)) {
|
|
1935
|
+
const projectId = toolProtectionService.getProjectId();
|
|
1936
|
+
if (!projectId) {
|
|
1937
|
+
return undefined;
|
|
1938
|
+
}
|
|
1939
|
+
const { envVarNames, headerMapping } = protection.authorization;
|
|
1940
|
+
try {
|
|
1941
|
+
// Lazy-init VaultResolver as class-level singleton so its per-session
|
|
1942
|
+
// cache persists across tool calls within the same DO instance.
|
|
1943
|
+
if (!this._vaultResolver) {
|
|
1944
|
+
const identity = await this.mcpiRuntime.getIdentity();
|
|
1945
|
+
// Decode base64/base64url private key to bytes (self-contained, no private method access)
|
|
1946
|
+
const b64 = identity.privateKey.replace(/-/g, "+").replace(/_/g, "/");
|
|
1947
|
+
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
|
|
1948
|
+
const binaryStr = atob(padded);
|
|
1949
|
+
const keyBytes = new Uint8Array(binaryStr.length);
|
|
1950
|
+
for (let i = 0; i < binaryStr.length; i++)
|
|
1951
|
+
keyBytes[i] = binaryStr.charCodeAt(i);
|
|
1952
|
+
// Detect key format and extract raw 32-byte Ed25519 key:
|
|
1953
|
+
// - PKCS#8 (48 bytes, 0x30 prefix): extract raw key at offset 16
|
|
1954
|
+
// - Raw 64-byte (seed+pub): take first 32
|
|
1955
|
+
// - Raw 32-byte: use as-is
|
|
1956
|
+
const rawKey = keyBytes.length === 48 && keyBytes[0] === 0x30
|
|
1957
|
+
? keyBytes.slice(16, 48)
|
|
1958
|
+
: keyBytes.length === 64
|
|
1959
|
+
? keyBytes.slice(0, 32)
|
|
1960
|
+
: keyBytes;
|
|
1961
|
+
// Wrap as PKCS#8 for crypto.subtle.importKey
|
|
1962
|
+
const pkcs8Header = new Uint8Array([
|
|
1963
|
+
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05,
|
|
1964
|
+
0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
|
|
1965
|
+
]);
|
|
1966
|
+
const pkcs8 = new Uint8Array(pkcs8Header.length + rawKey.length);
|
|
1967
|
+
pkcs8.set(pkcs8Header);
|
|
1968
|
+
pkcs8.set(rawKey, pkcs8Header.length);
|
|
1969
|
+
const identityKey = await crypto.subtle.importKey("pkcs8", pkcs8.buffer, { name: "Ed25519", namedCurve: "Ed25519" }, false, ["sign"]);
|
|
1970
|
+
this._vaultResolver = new VaultResolver({
|
|
1971
|
+
apiUrl: env.AGENTSHIELD_API_URL || "https://kya.vouched.id",
|
|
1972
|
+
apiKey: env.AGENTSHIELD_API_KEY,
|
|
1973
|
+
identityKey,
|
|
1974
|
+
agentDid: identity.did,
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1977
|
+
const secrets = await this._vaultResolver.resolve(projectId, userDid, toolName, envVarNames, sessionId || "ephemeral");
|
|
1978
|
+
// Build idpHeaders from generic headerMapping (not hardcoded header names)
|
|
1979
|
+
const idpHeaders = {};
|
|
1980
|
+
for (const [envVar, headerName] of Object.entries(headerMapping)) {
|
|
1981
|
+
if (secrets[envVar]) {
|
|
1982
|
+
idpHeaders[headerName] = secrets[envVar];
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
// If vault returned no usable secrets, check static env fallback.
|
|
1986
|
+
// If static env also lacks the required vars, throw a machine-readable
|
|
1987
|
+
// AuthRequiredError so MCP clients can present actionable guidance.
|
|
1988
|
+
if (Object.keys(idpHeaders).length === 0) {
|
|
1989
|
+
const hasStaticFallback = envVarNames.every((name) => !!env[name]);
|
|
1990
|
+
if (hasStaticFallback) {
|
|
1991
|
+
if (this._environment === "development") {
|
|
1992
|
+
logger.debug("[MCPICloudflareAgent] Vault returned no secrets, falling back to static env:", { tool: toolName, userDid: userDid?.slice(0, 20) + "..." });
|
|
1993
|
+
}
|
|
1994
|
+
return undefined;
|
|
1995
|
+
}
|
|
1996
|
+
// No vault secrets AND no static env — throw structured error
|
|
1997
|
+
const apiUrl = env.AGENTSHIELD_API_URL || "https://kya.vouched.id";
|
|
1998
|
+
// Use /dashboard/projects/{id} path — works without orgId and
|
|
1999
|
+
// the dashboard redirects to the correct org-scoped route.
|
|
2000
|
+
const configUrl = `${apiUrl}/dashboard/projects/${projectId}/settings`;
|
|
2001
|
+
throw new AuthRequiredError({
|
|
2002
|
+
type: "apikey",
|
|
2003
|
+
envVarNames,
|
|
2004
|
+
configUrl,
|
|
2005
|
+
message: `API key required: configure ${envVarNames.join(", ")} in your project settings. ` +
|
|
2006
|
+
`Visit ${configUrl} to add your keys.`,
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
if (this._environment === "development") {
|
|
2010
|
+
logger.debug("[MCPICloudflareAgent] Vault context built for apikey auth:", {
|
|
2011
|
+
tool: toolName,
|
|
2012
|
+
resolvedKeys: Object.keys(idpHeaders),
|
|
2013
|
+
userDid: userDid?.slice(0, 20) + "...",
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
return {
|
|
2017
|
+
idpHeaders,
|
|
2018
|
+
userDid,
|
|
2019
|
+
sessionId,
|
|
2020
|
+
provider: "vault",
|
|
2021
|
+
};
|
|
2022
|
+
}
|
|
2023
|
+
catch (vaultError) {
|
|
2024
|
+
// Re-throw AuthRequiredError — it means vault AND static env are both empty.
|
|
2025
|
+
// Must propagate so the tool-call handler can return a structured response.
|
|
2026
|
+
if (vaultError instanceof Error &&
|
|
2027
|
+
(vaultError.name === "AuthRequiredError" ||
|
|
2028
|
+
vaultError.constructor?.name === "AuthRequiredError")) {
|
|
2029
|
+
throw vaultError;
|
|
2030
|
+
}
|
|
2031
|
+
logger.warn("[MCPICloudflareAgent] Vault resolution failed, falling through:", vaultError);
|
|
2032
|
+
// Fall through — tool may still work with static env secrets (Level 0)
|
|
2033
|
+
return undefined;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
1305
2036
|
// Get project ID from tool protection service
|
|
1306
2037
|
const projectId = toolProtectionService.getProjectId();
|
|
1307
2038
|
if (!projectId) {
|
|
@@ -1384,6 +2115,13 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
1384
2115
|
) {
|
|
1385
2116
|
throw error;
|
|
1386
2117
|
}
|
|
2118
|
+
// Re-throw AuthRequiredError so it can be handled by executeToolWithProof
|
|
2119
|
+
// This error indicates missing per-user auth with no static env fallback
|
|
2120
|
+
if (error instanceof Error &&
|
|
2121
|
+
(error.name === "AuthRequiredError" ||
|
|
2122
|
+
error.constructor?.name === "AuthRequiredError")) {
|
|
2123
|
+
throw error;
|
|
2124
|
+
}
|
|
1387
2125
|
logger.warn("[MCPICloudflareAgent] Failed to build tool context:", error);
|
|
1388
2126
|
return undefined;
|
|
1389
2127
|
}
|
|
@@ -1927,6 +2665,20 @@ export class MCPICloudflareAgent extends McpAgent {
|
|
|
1927
2665
|
}), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
1928
2666
|
}
|
|
1929
2667
|
}
|
|
2668
|
+
// Handle internal client capabilities request
|
|
2669
|
+
// Returns stored MCP client capabilities from the initialize message
|
|
2670
|
+
if (url.pathname === "/_internal/client-capabilities" && request.method === "GET") {
|
|
2671
|
+
try {
|
|
2672
|
+
const mcpClientInfo = await this.getMcpClientInfo();
|
|
2673
|
+
return new Response(JSON.stringify({
|
|
2674
|
+
success: true,
|
|
2675
|
+
capabilities: mcpClientInfo?.capabilities || null,
|
|
2676
|
+
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
2677
|
+
}
|
|
2678
|
+
catch (error) {
|
|
2679
|
+
return new Response(JSON.stringify({ success: false, capabilities: null }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
1930
2682
|
// Handle internal cache-clear request
|
|
1931
2683
|
if (url.pathname === "/_do/cache-clear" && request.method === "POST") {
|
|
1932
2684
|
logger.info("[MCPICloudflareAgent] Handling internal cache-clear request");
|