@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/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}#keys-1`;
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&hellip;</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
- this.server.tool(name, description, zodSchemaShape, async (args, extra) => {
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
- logger.info("[MCPICloudflareAgent] 🔑 Credential auth required:", {
908
- tool: toolName,
909
- provider,
910
- scopes,
911
- consentUrl: consentUrl.substring(0, 80) + "...",
912
- });
913
- // ✅ FIX: Store error instead of throwing to ensure proper formatting
914
- pendingAuthError = new DelegationRequiredError(toolName, scopes, consentUrl, undefined, resumeToken);
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
- // For credential/password providers, build consent URL pointing to credential form
987
- logger.info("[MCPICloudflareAgent] 🔑 Credential auth required (from OAuthRequiredError):", {
988
- tool: oauthError.toolName,
989
- provider: oauthError.provider,
990
- scopes: oauthError.requiredScopes,
991
- });
992
- authUrl = await this.buildConsentUrlForCredentials(oauthError.toolName, oauthError.provider || "credentials", oauthError.requiredScopes, sessionId, mappedEnv);
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
- // Generate resume token using runtime's internal method
999
- // The runtime will generate it when DelegationRequiredError is thrown
1000
- const resumeToken = `resume_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
1001
- // ✅ FIX: Store error instead of throwing to ensure proper formatting
1002
- pendingAuthError = new DelegationRequiredError(oauthError.toolName, oauthError.requiredScopes, authUrl, undefined, // interceptedCall
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
- if (!pendingAuthError) {
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 pendingAuthError INSIDE the try block
1056
- // This ensures it's caught by the DelegationRequiredError formatter below
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 (MCP standard for user-visible errors)
1098
- // This format is what Claude Desktop and MCP Inspector display to users
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 services are configured
1162
- if (!userDid || !env.AGENTSHIELD_API_KEY || !env.DELEGATION_STORAGE) {
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");