Protobus Host Bridge (#3747)

* WIP host bridge

* Run formatter

* remove tmp impl & rename host grpc client

* gitignore more files

* better layout

* host handler to make other hosts easier to add

* remove adapter pattern

* get host responses correctly

* fix streaming mode for host bridge

* first wip subscription host bridge demo for watching mcp server config

* format, comment

* add cancellation for host grpc stream

* remove unneeded functions from host-grpc-handler

* add a method for canceling request rather than using the registry

* another todo

* use StringRequest for uri.proto

* debounce new file watcher

* remove test setup

* remove some todos and logs

* remove registry use todo

* Revert "remove registry use todo"

This reverts commit 84078d3469.

* fix capitalization of uri.proto

* a better pattern for a callback based bridge without using the requestRegistry directly

---------

Co-authored-by: Andrei Edell <andrei@nugbase.com>
Co-authored-by: Sarah Fortune <sarah.fortune@gmail.com>
Co-authored-by: Andrei Eternal <eternal@cline.bot>
This commit is contained in:
Andrei Eternal
2025-06-04 15:41:30 -07:00
committed by GitHub
co-authored by Andrei Edell Sarah Fortune Andrei Eternal
parent 47899ac52d
commit cbcd17764b
14 changed files with 1113 additions and 19 deletions
+11 -1
View File
@@ -3,6 +3,8 @@ import { RelativePathsRequest, RelativePaths } from "@shared/proto/file"
import { FileMethodHandler } from "./index"
import * as vscode from "vscode"
import * as path from "path"
import { UriServiceClient } from "../../../standalone/services/host-grpc-client"
import { Metadata, StringRequest } from "@shared/proto/common"
/**
* Converts a list of URIs to workspace-relative paths
@@ -17,7 +19,15 @@ export const getRelativePaths: FileMethodHandler = async (
const resolvedPaths = await Promise.all(
request.uris.map(async (uriString) => {
try {
const fileUri = vscode.Uri.parse(uriString, true)
// Use the host URI service client instead of directly using vscode.Uri.parse
const parseResponse = await UriServiceClient.parse(
StringRequest.create({
metadata: Metadata.create({}),
value: uriString,
}),
)
const fileUri = vscode.Uri.parse(`${parseResponse.scheme}://${parseResponse.authority}${parseResponse.path}`)
console.log("[DEBUG] UriServiceClient.parse:", fileUri)
const relativePathToGet = vscode.workspace.asRelativePath(fileUri, false)
// If the path is still absolute, it's outside the workspace
+1
View File
@@ -1,6 +1,7 @@
import { Controller } from ".."
import { Empty } from "../../../shared/proto/common"
import { NewTaskRequest } from "../../../shared/proto/task"
import { handleFileServiceRequest } from "../file"
/**
* Creates a new task with the given text and optional images
+39 -13
View File
@@ -17,6 +17,9 @@ import * as fs from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import { z } from "zod"
import { WatchServiceClient } from "../../standalone/services/host-grpc-client"
import { FileChangeEvent_ChangeType, SubscribeToFileRequest } from "../../shared/proto/host/watch"
import { Metadata } from "../../shared/proto/common"
import {
DEFAULT_MCP_TIMEOUT_SECONDS,
McpMode,
@@ -118,22 +121,45 @@ export class McpHub {
private async watchMcpSettingsFile(): Promise<void> {
const settingsPath = await this.getMcpSettingsFilePath()
this.disposables.push(
vscode.workspace.onDidSaveTextDocument(async (document) => {
if (arePathsEqual(document.uri.fsPath, settingsPath)) {
const settings = await this.readAndValidateMcpSettingsFile()
if (settings) {
try {
vscode.window.showInformationMessage("Updating MCP servers...")
await this.updateServerConnections(settings.mcpServers)
vscode.window.showInformationMessage("MCP servers updated")
} catch (error) {
console.error("Failed to process MCP settings change:", error)
// Subscribe to file changes using the gRPC WatchService
console.log("[DEBUG] subscribing to mcp file changes")
const cancelSubscription = WatchServiceClient.subscribeToFile(
SubscribeToFileRequest.create({
metadata: Metadata.create({}),
path: settingsPath,
}),
{
onResponse: async (response) => {
console.log(
`[DEBUG] MCP settings ${response.type === FileChangeEvent_ChangeType.CHANGED ? "changed" : "event"}`,
)
// Only process the file if it was changed (not created or deleted)
if (response.type === FileChangeEvent_ChangeType.CHANGED) {
const settings = await this.readAndValidateMcpSettingsFile()
if (settings) {
try {
vscode.window.showInformationMessage("Updating MCP servers...")
await this.updateServerConnections(settings.mcpServers)
vscode.window.showInformationMessage("MCP servers updated")
} catch (error) {
console.error("Failed to process MCP settings change:", error)
}
}
}
}
}),
},
onError: (error) => {
console.error("Error watching MCP settings file:", error)
},
onComplete: () => {
console.log("[DEBUG] MCP settings file watch completed")
},
},
)
// Add the cancellation function to disposables
this.disposables.push({ dispose: cancelSubscription })
}
private async initializeMcpServers(): Promise<void> {
@@ -0,0 +1,106 @@
import { v4 as uuidv4 } from "uuid"
import { GrpcHandler, StreamingCallbacks } from "../../../hosts/vscode/host-grpc-handler"
// Generic type for any protobuf service definition
export type ProtoService = {
name: string
fullName: string
methods: {
[key: string]: {
name: string
requestType: any
responseType: any
requestStream: boolean
responseStream: boolean
options: any
}
}
}
// Define a unified client type that handles both unary and streaming methods
export type GrpcClientType<T extends ProtoService> = {
[K in keyof T["methods"]]: T["methods"][K]["responseStream"] extends true
? (
request: InstanceType<T["methods"][K]["requestType"]>,
options: StreamingCallbacks<InstanceType<T["methods"][K]["responseType"]>>,
) => () => void // Returns a cancel function
: (request: InstanceType<T["methods"][K]["requestType"]>) => Promise<InstanceType<T["methods"][K]["responseType"]>>
}
// Create a client for any protobuf service with inferred types
export function createGrpcClient<T extends ProtoService>(service: T): GrpcClientType<T> {
const client = {} as GrpcClientType<T>
const grpcHandler = new GrpcHandler()
Object.values(service.methods).forEach((method) => {
// Streaming method implementation
if (method.responseStream) {
// Use lowercase method name as the key in the client object
const methodKey = method.name.charAt(0).toLowerCase() + method.name.slice(1)
client[methodKey as keyof GrpcClientType<T>] = ((
request: any,
options: StreamingCallbacks<InstanceType<typeof method.responseType>>,
) => {
// Use handleRequest with streaming callbacks
const requestId = uuidv4()
console.log(`[DEBUG] Streaming gRPC host call to ${service.fullName}.${methodKey} req:${requestId}`)
// We need to await the promise and then return the cancel function
return (async () => {
try {
const result = await grpcHandler.handleRequest<InstanceType<typeof method.responseType>>(
service.fullName,
methodKey,
request,
requestId,
options,
)
// If the result is a function, it's the cancel function
if (typeof result === "function") {
return result
} else {
// This shouldn't happen, but just in case
console.error(`Expected cancel function but got response object for streaming request: ${requestId}`)
return () => {}
}
} catch (error) {
console.error(`Error in streaming request: ${error}`)
if (options.onError) {
options.onError(error instanceof Error ? error : new Error(String(error)))
}
return () => {}
}
})()
}) as any
} else {
// Unary method implementation
const methodKey = method.name.charAt(0).toLowerCase() + method.name.slice(1)
client[methodKey as keyof GrpcClientType<T>] = ((request: any) => {
return new Promise(async (resolve, reject) => {
const requestId = uuidv4()
console.log(`[DEBUG] gRPC host call to ${service.fullName}.${methodKey} req:${requestId}`)
try {
const response = await grpcHandler.handleRequest(service.fullName, methodKey, request, requestId)
console.log(`[DEBUG] gRPC host resp to ${service.fullName}.${methodKey} req:${requestId}`)
// Check if the response is a function (streaming) or an object (unary)
if (typeof response === "function") {
// This shouldn't happen for unary requests
throw new Error("Received streaming response for unary request")
} else if (response && response.message) {
resolve(response.message)
} else {
throw new Error("gRPC response didn't have a message")
}
} catch (e) {
console.log(`[DEBUG] gRPC host ERR to ${service.fullName}.${methodKey} req:${requestId} err:${e}`)
reject(e)
}
})
}) as any
}
})
return client
}