Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add MCP tool conversion util #5003

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

iteratetograceness
Copy link
Contributor

No description provided.

@lgrammel
Copy link
Collaborator

Awesome - left some comments. It would be great to have an example under /examples/ai-core that shows how to use these tools again some mcp server (maybe they have an example server, or there is a common one).

@lgrammel
Copy link
Collaborator

@iteratetograceness actually would it be possible to call the MCP websocket api directly without their library? if so, how hard would it be? that would solve the dependency issues and allow us to include the MCP tool directly in the ai pkg. It would also be more aligned with our general direct api calling approach (avoid libraries).

@iteratetograceness
Copy link
Contributor Author

would it be possible to call the MCP websocket api directly without their library? if so, how hard would it be? that would solve the dependency issues and allow us to include the MCP tool directly in the ai pkg. It would also be more aligned with our general direct api calling approach (avoid libraries).

Thinking about this: seems like what we want is a custom Client and Transport; the trade-off here being we would essentially be copying over chunks of their code that we want to use

@lgrammel
Copy link
Collaborator

Thinking about this: seems like what we want is a custom Client and Transport; the trade-off here being we would essentially be copying over chunks of their code that we want to use

I usually re-develop from scratch against APIs to make sure the code is up to our standards.

Comment on lines +37 to +38
/** Optional client version, defaults to '1.0.0' */
version?: string;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm the client version is what we support, right? so should the user be able to specify it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

client config is more informational, not really used in a critical capacity (e.g. compatibility checking b/w client and server) - i'd like to advocate for keeping it so that users can debug/track specific clients they create if necessary!


return this;
} catch (error) {
void this.close();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

void is not needed? also, what are the implications of calling close here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

noted on void! calling close here ensures we clean up the transport/connection and clear our response handlers

Comment on lines 296 to 305
const responseHandlers = this.responseHandlers;
this.responseHandlers = new Map();

const error = new MCPClientError({
message: 'Connection closed',
});

for (const handler of responseHandlers.values()) {
handler(error);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would the actual connection need to be closed? also, why do we need to swap response handlers? can we set an isClosed flag on the object instead?

also, i prefer onError, onClose

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes! we invoke this.transport?.close() when this.onclose is called!

Comment on lines 308 to 313
private onerror(error: Error): void {
throw new MCPClientError({
message: error.message,
cause: error,
});
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what will this do in practice (in terms of the stack and where the error shows up)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, good point! since the client's onError (which is triggered via the transport's onerror handler or onResponse) we aren't properly propagating this up within the client

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of throwing MCPClientError, should we just close the client+transport? we need a way to properly bubble up the error to the user hmmmmm


let buffer = '';

const processEvents = async () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use the eventstream parser package that we are already using in provider utils?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes!


buffer += textDecoder.decode(value, { stream: true });
const events = buffer.split('\n\n');
buffer = events.pop() || '';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

??

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added in case we get a chunk of that includes the beginning of the next event!

Comment on lines +336 to +374
const DEFAULT_INHERITED_ENV_VARS =
process.platform === 'win32'
? [
'APPDATA',
'HOMEDRIVE',
'HOMEPATH',
'LOCALAPPDATA',
'PATH',
'PROCESSOR_ARCHITECTURE',
'SYSTEMDRIVE',
'SYSTEMROOT',
'TEMP',
'USERNAME',
'USERPROFILE',
]
: ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER'];

function getDefaultEnvironment(): Record<string, string> {
const env: Record<string, string> = {};

for (const key of DEFAULT_INHERITED_ENV_VARS) {
const value = process.env[key];
if (value === undefined) {
continue;
}

if (value.startsWith('()')) {
continue;
}

env[key] = value;
}

return env;
}

function isElectron() {
return 'type' in process;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

used for config when spawning child_process!

Comment on lines +94 to +141
const RequestIdSchema = z.union([z.string(), z.number().int()]);
const JSONRPCRequestSchema = z
.object({
jsonrpc: z.literal(JSONRPC_VERSION),
id: RequestIdSchema,
})
.merge(RequestSchema)
.strict();
export type JSONRPCRequest = z.infer<typeof JSONRPCRequestSchema>;
const JSONRPCResponseSchema = z
.object({
jsonrpc: z.literal(JSONRPC_VERSION),
id: RequestIdSchema,
result: ResultSchema,
})
.strict();
export type JSONRPCResponse = z.infer<typeof JSONRPCResponseSchema>;
const JSONRPCErrorSchema = z
.object({
jsonrpc: z.literal(JSONRPC_VERSION),
id: RequestIdSchema,
error: z.object({
code: z.number().int(),
message: z.string(),
data: z.optional(z.unknown()),
}),
})
.strict();
export type JSONRPCError = z.infer<typeof JSONRPCErrorSchema>;
const JSONRPCNotificationSchema = z
.object({
jsonrpc: z.literal(JSONRPC_VERSION),
})
.merge(NotificationSchema)
.strict();
export type JSONRPCNotification = z.infer<typeof JSONRPCNotificationSchema>;

export const JSONRPCMessageSchema = z.union([
JSONRPCRequestSchema,
JSONRPCNotificationSchema,
JSONRPCResponseSchema,
JSONRPCErrorSchema,
]);
export type JSONRPCMessage = z.infer<typeof JSONRPCMessageSchema>;

export interface MCPTransport {
start(): Promise<void>;
send(message: JSONRPCMessage): Promise<void>;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need zod schemas for all of this (as in, we use the schemas), or can we go more lightweight and just define TS types (at least for some)?

@@ -104,6 +122,7 @@ export function createTestServer<URLS extends { [url: string]: UrlHandler }>(
): {
urls: FullHandlers<URLS>;
calls: TestServerCall[];
addSseEvent: (url: string, data: any, event?: string, id?: string) => void;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wondering about this because it is sse specific. there is another pattern i used, see e.g. https://github.com/vercel/ai/blob/main/packages/react/src/use-chat.ui.test.tsx#L880

wondering if that would be an alternative (maybe it's the same thing and addServerSentEvent or addSSE (rename?) is more elegant

Copy link
Collaborator

@lgrammel lgrammel Mar 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also do we want to be SSE specific here? this could just a generic stream and you could attach SSE formatted chunks (we could also expose formatSSEChunk as a separate helper)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my approach is missing a sort of streamController like your approach is 👀 hoping to discuss in call tomo!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants