Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/controllers/ExtensionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,11 +341,13 @@ export class ExtensionController implements IDisposable {
initializePanelController = (): void => {
assertDefined(this._panelService, 'panelService');
assertDefined(this._serverManager, 'serverManager');
assertDefined(this._outputChannel, 'outputChannel');

this._panelController = new PanelController(
this._context.extensionUri,
this._serverManager,
this._panelService
this._panelService,
this._outputChannel
);

this._context.subscriptions.push(this._panelController);
Expand Down
97 changes: 89 additions & 8 deletions src/controllers/PanelController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,28 @@ import {
import { waitFor } from '../util/promiseUtils';
import { getEmbedWidgetUrl } from '../dh/dhc';
import { ControllerBase } from './ControllerBase';
import { assertDefined, DH_POST_MSG } from '../shared';
import {
assertDefined,
DH_POST_MSG,
isErrorNotificationFromDh,
} from '../shared';
import type { OutputChannelWithHistory } from '../util';

const logger = new Logger('PanelController');

export class PanelController extends ControllerBase {
constructor(
extensionUri: vscode.Uri,
serverManager: IServerManager,
panelService: IPanelService
panelService: IPanelService,
outputChannel: OutputChannelWithHistory
) {
super();

this._extensionUri = extensionUri;
this._panelService = panelService;
this._serverManager = serverManager;
this._outputChannel = outputChannel;

this.registerCommand(OPEN_VARIABLE_PANELS_CMD, this._onOpenPanels);
this.registerCommand(
Expand All @@ -65,6 +72,7 @@ export class PanelController extends ControllerBase {
private readonly _extensionUri: vscode.Uri;
private readonly _panelService: IPanelService;
private readonly _serverManager: IServerManager;
private readonly _outputChannel: OutputChannelWithHistory;

private readonly _lastPanelInViewColumn = new Map<
vscode.ViewColumn | undefined,
Expand Down Expand Up @@ -141,15 +149,88 @@ export class PanelController extends ControllerBase {
* See `getPanelHtml` util for the panel html which wires up the `postMessage`
* communication between the extension and the DH iframe.
* @param serverOrWorkerUrl The server or worker url.
* @param message The message data.
* @param variable The variable definition for this panel.
* @param message The message data (can be legacy format or JSON-RPC 2.0 format).
* @param postResponseMessage The function to post a response message.
* @returns A promise that resolves when the message has been handled.
*/
private async _onPanelMessage(
serverOrWorkerUrl: URL | WorkerURL,
{ id, message }: { id: string; message: string },
variable: VariableDefintion,
message: unknown,
postResponseMessage: (response: unknown) => void
): Promise<void> {
// Handle JSON-RPC 2.0 error notifications from Deephaven iframe
if (isErrorNotificationFromDh(message as any)) {
const { level, logger: loggerName, data } = (message as any).params;

// Extract message and stack for better formatting
let errorMessage: string;
let stackTrace: string | undefined;

if (typeof data === 'string') {
errorMessage = data;
} else if (data && typeof data === 'object') {
errorMessage =
'message' in data ? String(data.message) : JSON.stringify(data);
stackTrace =
'stack' in data && typeof data.stack === 'string'
? data.stack
: undefined;
} else {
errorMessage = JSON.stringify(data);
}

// Log full notification details to debug output (stacktrace-like format)
let debugOutput =
`Error notification from Deephaven panel '${variable.title}':\n` +
` Level: ${level}\n` +
` Logger: ${loggerName || 'unknown'}\n` +
` Message: ${errorMessage}`;

if (stackTrace) {
// Add stack trace with proper indentation
debugOutput +=
`\n Stack:\n` +
stackTrace
.split('\n')
.map(line => ` ${line}`)
.join('\n');
}

logger.debug(debugOutput);

// Log to output channel with variable name and appropriate level prefix
const prefix = `[${variable.title}] `;
if (
level === 'error' ||
level === 'critical' ||
level === 'alert' ||
level === 'emergency'
) {
this._outputChannel.appendLine(`${prefix}ERROR: ${errorMessage}`);
} else if (level === 'warning') {
this._outputChannel.appendLine(`${prefix}WARNING: ${errorMessage}`);
} else {
this._outputChannel.appendLine(`${prefix}${errorMessage}`);
}

return;
}

// Handle legacy format messages (login/session requests)
if (
typeof message !== 'object' ||
message == null ||
!('id' in message) ||
!('message' in message)
) {
logger.debug('Unknown message format', message);
return;
}

const { id, message: msgType } = message as { id: string; message: string };

const workerInfo = await this._serverManager.getWorkerInfo(
serverOrWorkerUrl as WorkerURL
);
Expand All @@ -159,7 +240,7 @@ export class PanelController extends ControllerBase {
}

// Respond to login credentials request from DH iframe
if (message === DH_POST_MSG.loginOptionsRequest) {
if (msgType === DH_POST_MSG.loginOptionsRequest) {
const credentials =
await this._serverManager.getWorkerCredentials(serverOrWorkerUrl);

Expand Down Expand Up @@ -191,7 +272,7 @@ export class PanelController extends ControllerBase {
}

// Respond to session details request from DH iframe
if (message === DH_POST_MSG.sessionDetailsRequest) {
if (msgType === DH_POST_MSG.sessionDetailsRequest) {
const response = createSessionDetailsResponsePostMessage({
id,
workerInfo,
Expand All @@ -204,7 +285,7 @@ export class PanelController extends ControllerBase {
return;
}

logger.debug('Unknown message type', message);
logger.debug('Unknown message type', msgType);
}

/**
Expand Down Expand Up @@ -259,7 +340,7 @@ export class PanelController extends ControllerBase {
const onDidReceiveMessageSubscription =
panel.webview.onDidReceiveMessage(({ data }) => {
const postMessage = panel.webview.postMessage.bind(panel.webview);
this._onPanelMessage(serverUrl, data, postMessage);
this._onPanelMessage(serverUrl, variable, data, postMessage);
});

this._panelService.setPanel(serverUrl, id, panel);
Expand Down
1 change: 1 addition & 0 deletions src/dh/dhc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export function getEmbedWidgetUrl({

url.searchParams.set('name', title);
url.searchParams.set('theme', themeKey);
url.searchParams.set('errorNotifications', 'true');

if (authProvider) {
url.searchParams.set('authProvider', authProvider);
Expand Down
143 changes: 134 additions & 9 deletions src/mcp/McpServer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as vscode from 'vscode';
import type { dh as DhcType } from '@deephaven/jsapi-types';
import { randomUUID } from 'node:crypto';
import { McpServer as SdkMcpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import * as http from 'http';
import type {
IAsyncCacheService,
Expand All @@ -14,6 +16,7 @@ import { MCP_SERVER_NAME } from '../common';
import {
createAddRemoteFileSourcesTool,
createGetColumnStatsTool,
createDisplayPanelWidgetTool,
createGetLogsTool,
createGetTableDataTool,
createGetTableStatsTool,
Expand All @@ -32,6 +35,7 @@ import {
import { OutputChannelWithHistory, withResolvers } from '../util';
import { DisposableBase, type FilteredWorkspace } from '../services';
import { createConnectToServerTool } from './tools/connectToServer';
import { DEEPHAVEN_PANEL_UI } from './ui/deephaven-panel.js';

/**
* MCP Server for Deephaven extension.
Expand All @@ -41,6 +45,7 @@ export class McpServer extends DisposableBase {
private server: SdkMcpServer;
private httpServer: http.Server | null = null;
private port: number | null = null;
private transports = new Map<string, StreamableHTTPServerTransport>();

constructor(
readonly coreJsApiCache: IAsyncCacheService<URL, typeof DhcType>,
Expand All @@ -62,6 +67,7 @@ export class McpServer extends DisposableBase {
this.registerTool(createAddRemoteFileSourcesTool());
this.registerTool(createConnectToServerTool(this));
this.registerTool(createGetColumnStatsTool(this));
this.registerTool(createDisplayPanelWidgetTool(this));
this.registerTool(createGetLogsTool(this));
this.registerTool(createGetTableDataTool(this));
this.registerTool(createGetTableStatsTool(this));
Expand All @@ -76,6 +82,64 @@ export class McpServer extends DisposableBase {
this.registerTool(createRunCodeTool(this));
this.registerTool(createSetEditorConnectionTool(this));
this.registerTool(createShowOutputPanelTool(this));

/**
* Register UI resource for Deephaven panel widget.
* This enables MCP hosts (like GitHub Copilot) to render interactive Deephaven panels.
*
* Resource URIs include variable title as path segment for uniqueness:
* ui://deephaven/panel/{variableTitle}
*
* The panelUrl is extracted from structuredContent.details.panelUrl in the tool result
* notification.
*
* CSP configuration: Uses frameDomains to allow loading panels from all configured
* Deephaven servers. Per MCP Apps spec, frameDomains maps to CSP frame-src directive.
*/
this.server.registerResource(
'deephaven-panel-ui',
'ui://deephaven/panel',
{
description: 'Interactive Deephaven panel widget display',
mimeType: 'text/html;profile=mcp-app',
},
async uri => {
this.outputChannelDebug.appendLine(
`[MCP] Resource request for URI: ${uri.href}`
);

// Get origins from all configured servers for CSP
const serverOrigins = this.serverManager
.getServers()
.map(server => server.url.origin);

this.outputChannelDebug.appendLine(
`[MCP] Allowing origins in CSP: ${serverOrigins.join(', ')}`
);

// Allow loading Deephaven panels from all configured servers
// Using frameDomains (not frameSrc) per MCP Apps spec
const csp = {
frameDomains: serverOrigins,
};

return {
contents: [
{
uri: uri.href,
mimeType: 'text/html;profile=mcp-app',
text: DEEPHAVEN_PANEL_UI(),
_meta: {
ui: {
csp,
prefersBorder: false,
},
},
},
],
};
}
);
}

private registerTool<Spec extends McpToolSpec>({
Expand Down Expand Up @@ -124,18 +188,65 @@ export class McpServer extends DisposableBase {
req.on('end', async () => {
try {
const requestBody = JSON.parse(body);
const sessionId = req.headers['mcp-session-id'] as string | undefined;

// Create a new transport for each request
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
let transport: StreamableHTTPServerTransport;

res.on('close', () => {
transport.close();
});
if (sessionId && this.transports.has(sessionId)) {
// Reuse existing transport for this session
transport = this.transports.get(sessionId)!;
this.outputChannelDebug.appendLine(
`[MCP] Reusing transport for session: ${sessionId}`
);
} else if (!sessionId && isInitializeRequest(requestBody)) {
// New initialization request - create new transport
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: (): string => randomUUID(),
onsessioninitialized: (sessionId): void => {
this.outputChannelDebug.appendLine(
`[MCP] Session initialized: ${sessionId}`
);
this.transports.set(sessionId, transport);
},
onsessionclosed: (sessionId): void => {
this.outputChannelDebug.appendLine(
`[MCP] Session closed: ${sessionId}`
);
this.transports.delete(sessionId);
},
enableJsonResponse: true,
});

await this.server.connect(transport);
// Clean up transport when closed
transport.onclose = (): void => {
const sid = transport.sessionId;
if (sid && this.transports.has(sid)) {
this.outputChannelDebug.appendLine(
`[MCP] Transport closed for session ${sid}`
);
this.transports.delete(sid);
}
};

// Connect the transport to the MCP server (only once for new sessions)
await this.server.connect(transport);
} else {
// Invalid request - no session ID or not an initialization request
res.writeHead(400, { contentType: 'application/json' });
res.end(
JSON.stringify({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided',
},
id: null,
})
);
return;
}

// Handle the request with the transport
await transport.handleRequest(req, res, requestBody);
} catch (error) {
res.writeHead(500, { contentType: 'application/json' });
Expand Down Expand Up @@ -207,6 +318,20 @@ export class McpServer extends DisposableBase {
return;
}

// Clean up all active sessions
if (this.transports.size > 0) {
this.outputChannelDebug.appendLine(
`[MCP] Cleaning up ${this.transports.size} active session(s)`
);
for (const [sessionId, transport] of this.transports.entries()) {
this.outputChannelDebug.appendLine(
`[MCP] Closing session: ${sessionId}`
);
transport.close();
}
this.transports.clear();
}

const { resolve, reject, promise } = withResolvers<void>();

this.httpServer.close(err => {
Expand Down
Loading
Loading