@radaros/core 0.1.0 → 0.3.0

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.
@@ -0,0 +1,264 @@
1
+ import type { ToolDef, ToolResult } from "../tools/types.js";
2
+ import type { RunContext } from "../agent/run-context.js";
3
+
4
+ export interface MCPToolProviderConfig {
5
+ name: string;
6
+ transport: "stdio" | "http";
7
+ /** For stdio transport: command to spawn */
8
+ command?: string;
9
+ /** For stdio transport: args for the command */
10
+ args?: string[];
11
+ /** For stdio transport: environment variables */
12
+ env?: Record<string, string>;
13
+ /** For http transport: server URL */
14
+ url?: string;
15
+ /** For http transport: custom headers */
16
+ headers?: Record<string, string>;
17
+ }
18
+
19
+ /**
20
+ * Connects to an MCP (Model Context Protocol) server and exposes its tools
21
+ * as native RadarOS ToolDef[] that any Agent can use.
22
+ *
23
+ * Supports stdio and HTTP (Streamable HTTP) transports.
24
+ * Requires: npm install @modelcontextprotocol/sdk
25
+ */
26
+ export class MCPToolProvider {
27
+ readonly name: string;
28
+ private config: MCPToolProviderConfig;
29
+ private client: any = null;
30
+ private transportInstance: any = null;
31
+ private tools: ToolDef[] = [];
32
+ private connected = false;
33
+
34
+ constructor(config: MCPToolProviderConfig) {
35
+ this.name = config.name;
36
+ this.config = config;
37
+ }
38
+
39
+ async connect(): Promise<void> {
40
+ if (this.connected) return;
41
+
42
+ let ClientClass: any;
43
+ try {
44
+ const mod = await import("@modelcontextprotocol/sdk/client/index.js");
45
+ ClientClass = mod.Client;
46
+ } catch {
47
+ throw new Error(
48
+ "@modelcontextprotocol/sdk is required for MCPToolProvider. Install it: npm install @modelcontextprotocol/sdk"
49
+ );
50
+ }
51
+
52
+ this.client = new ClientClass(
53
+ { name: `radaros-${this.name}`, version: "1.0.0" },
54
+ { capabilities: {} }
55
+ );
56
+
57
+ if (this.config.transport === "stdio") {
58
+ if (!this.config.command) {
59
+ throw new Error("MCPToolProvider: 'command' is required for stdio transport");
60
+ }
61
+ const { StdioClientTransport } = await import(
62
+ "@modelcontextprotocol/sdk/client/stdio.js"
63
+ );
64
+
65
+ this.transportInstance = new StdioClientTransport({
66
+ command: this.config.command,
67
+ args: this.config.args ?? [],
68
+ env: { ...process.env, ...(this.config.env ?? {}) } as Record<string, string>,
69
+ });
70
+ } else if (this.config.transport === "http") {
71
+ if (!this.config.url) {
72
+ throw new Error("MCPToolProvider: 'url' is required for http transport");
73
+ }
74
+
75
+ let TransportClass: any;
76
+ try {
77
+ const mod = await import(
78
+ "@modelcontextprotocol/sdk/client/streamableHttp.js"
79
+ );
80
+ TransportClass = mod.StreamableHTTPClientTransport;
81
+ } catch {
82
+ const mod = await import("@modelcontextprotocol/sdk/client/sse.js");
83
+ TransportClass = mod.SSEClientTransport;
84
+ }
85
+
86
+ this.transportInstance = new TransportClass(
87
+ new URL(this.config.url),
88
+ { requestInit: { headers: this.config.headers ?? {} } }
89
+ );
90
+ } else {
91
+ throw new Error(`MCPToolProvider: unsupported transport '${this.config.transport}'`);
92
+ }
93
+
94
+ await this.client.connect(this.transportInstance);
95
+ this.connected = true;
96
+ await this.discoverTools();
97
+ }
98
+
99
+ private async discoverTools(): Promise<void> {
100
+ const { z } = await import("zod");
101
+ const result = await this.client.listTools();
102
+ const mcpTools: any[] = result.tools ?? [];
103
+
104
+ this.tools = mcpTools.map((mcpTool: any) => {
105
+ const toolName = mcpTool.name;
106
+ const description = mcpTool.description ?? "";
107
+ const inputSchema = mcpTool.inputSchema ?? { type: "object", properties: {} };
108
+
109
+ const parameters = this.jsonSchemaToZod(inputSchema, z);
110
+
111
+ const execute = async (
112
+ args: Record<string, unknown>,
113
+ _ctx: RunContext
114
+ ): Promise<string | ToolResult> => {
115
+ const callResult = await this.client.callTool({
116
+ name: toolName,
117
+ arguments: args,
118
+ });
119
+
120
+ const contents: any[] = callResult.content ?? [];
121
+ const textParts = contents
122
+ .filter((c: any) => c.type === "text")
123
+ .map((c: any) => c.text);
124
+
125
+ const text = textParts.join("\n") || JSON.stringify(callResult);
126
+
127
+ const artifacts = contents
128
+ .filter((c: any) => c.type !== "text")
129
+ .map((c: any) => ({
130
+ type: c.type,
131
+ data: c.data ?? c.blob ?? c.text,
132
+ mimeType: c.mimeType,
133
+ }));
134
+
135
+ if (artifacts.length > 0) {
136
+ return { content: text, artifacts };
137
+ }
138
+ return text;
139
+ };
140
+
141
+ return {
142
+ name: `${this.name}__${toolName}`,
143
+ description: `[${this.name}] ${description}`,
144
+ parameters,
145
+ execute,
146
+ rawJsonSchema: inputSchema,
147
+ } satisfies ToolDef;
148
+ });
149
+ }
150
+
151
+ private jsonSchemaToZod(schema: any, z: any): any {
152
+ if (!schema || !schema.properties) {
153
+ return z.object({}).passthrough();
154
+ }
155
+
156
+ const shape: Record<string, any> = {};
157
+ const required: string[] = schema.required ?? [];
158
+
159
+ for (const [key, prop] of Object.entries(schema.properties) as [string, any][]) {
160
+ let field: any;
161
+
162
+ switch (prop.type) {
163
+ case "string":
164
+ field = z.string();
165
+ if (prop.enum) field = z.enum(prop.enum);
166
+ break;
167
+ case "number":
168
+ case "integer":
169
+ field = z.number();
170
+ break;
171
+ case "boolean":
172
+ field = z.boolean();
173
+ break;
174
+ case "array":
175
+ field = z.array(z.any());
176
+ break;
177
+ case "object":
178
+ field = z.record(z.any());
179
+ break;
180
+ default:
181
+ field = z.any();
182
+ }
183
+
184
+ if (prop.description) {
185
+ field = field.describe(prop.description);
186
+ }
187
+
188
+ if (!required.includes(key)) {
189
+ field = field.optional();
190
+ }
191
+
192
+ shape[key] = field;
193
+ }
194
+
195
+ return z.object(shape);
196
+ }
197
+
198
+ /**
199
+ * Returns tools from this MCP server as RadarOS ToolDef[].
200
+ * Optionally filter by tool names to reduce token usage.
201
+ *
202
+ * @param filter - Tool names to include (without the server name prefix).
203
+ * If omitted, returns all tools.
204
+ *
205
+ * @example
206
+ * // All tools
207
+ * await mcp.getTools()
208
+ *
209
+ * // Only specific tools (pass the original MCP tool names, not prefixed)
210
+ * await mcp.getTools({ include: ["get_latest_release", "search_repositories"] })
211
+ *
212
+ * // Exclude specific tools
213
+ * await mcp.getTools({ exclude: ["push_files", "create_repository"] })
214
+ */
215
+ async getTools(filter?: {
216
+ include?: string[];
217
+ exclude?: string[];
218
+ }): Promise<ToolDef[]> {
219
+ if (!this.connected) {
220
+ await this.connect();
221
+ }
222
+
223
+ if (!filter) {
224
+ return [...this.tools];
225
+ }
226
+
227
+ const prefix = `${this.name}__`;
228
+
229
+ return this.tools.filter((tool) => {
230
+ const shortName = tool.name.startsWith(prefix)
231
+ ? tool.name.slice(prefix.length)
232
+ : tool.name;
233
+
234
+ if (filter.include) {
235
+ return filter.include.includes(shortName);
236
+ }
237
+ if (filter.exclude) {
238
+ return !filter.exclude.includes(shortName);
239
+ }
240
+ return true;
241
+ });
242
+ }
243
+
244
+ /** Refresh the tool list from the MCP server. */
245
+ async refresh(): Promise<void> {
246
+ if (!this.connected) {
247
+ throw new Error("MCPToolProvider: not connected. Call connect() first.");
248
+ }
249
+ await this.discoverTools();
250
+ }
251
+
252
+ /** Disconnect from the MCP server. */
253
+ async close(): Promise<void> {
254
+ if (this.client && this.connected) {
255
+ try {
256
+ await this.client.close();
257
+ } catch {
258
+ // ignore close errors
259
+ }
260
+ this.connected = false;
261
+ this.tools = [];
262
+ }
263
+ }
264
+ }
@@ -0,0 +1,400 @@
1
+ import { createRequire } from "node:module";
2
+ import type { ModelProvider } from "../provider.js";
3
+ import {
4
+ getTextContent,
5
+ isMultiModal,
6
+ type ChatMessage,
7
+ type ContentPart,
8
+ type ModelConfig,
9
+ type ModelResponse,
10
+ type StreamChunk,
11
+ type ToolDefinition,
12
+ type TokenUsage,
13
+ type ToolCall,
14
+ } from "../types.js";
15
+
16
+ const _require = createRequire(import.meta.url);
17
+
18
+ export interface VertexAIConfig {
19
+ project?: string;
20
+ location?: string;
21
+ /** Service account key JSON string or path (optional — uses ADC by default). */
22
+ credentials?: string;
23
+ }
24
+
25
+ /**
26
+ * Vertex AI provider using Google's @google/genai SDK in Vertex mode.
27
+ *
28
+ * Authentication (in order of precedence):
29
+ * 1. Explicit `project` + `location` in config
30
+ * 2. GOOGLE_CLOUD_PROJECT / GOOGLE_CLOUD_LOCATION env vars
31
+ * 3. Application Default Credentials (gcloud auth)
32
+ */
33
+ export class VertexAIProvider implements ModelProvider {
34
+ readonly providerId = "vertex";
35
+ readonly modelId: string;
36
+ private ai: any = null;
37
+ private GoogleGenAICtor: any;
38
+ private project: string;
39
+ private location: string;
40
+
41
+ constructor(modelId: string, config?: VertexAIConfig) {
42
+ this.modelId = modelId;
43
+ this.project =
44
+ config?.project ??
45
+ process.env.GOOGLE_CLOUD_PROJECT ??
46
+ process.env.GCLOUD_PROJECT ??
47
+ "";
48
+ this.location =
49
+ config?.location ??
50
+ process.env.GOOGLE_CLOUD_LOCATION ??
51
+ process.env.GOOGLE_CLOUD_REGION ??
52
+ "us-central1";
53
+
54
+ if (!this.project) {
55
+ throw new Error(
56
+ "VertexAIProvider: 'project' is required. Pass it in config or set GOOGLE_CLOUD_PROJECT env var."
57
+ );
58
+ }
59
+
60
+ try {
61
+ const { GoogleGenAI } = _require("@google/genai");
62
+ this.GoogleGenAICtor = GoogleGenAI;
63
+ } catch {
64
+ throw new Error(
65
+ "@google/genai is required for VertexAIProvider. Install it: npm install @google/genai"
66
+ );
67
+ }
68
+ }
69
+
70
+ private getClient(): any {
71
+ if (this.ai) return this.ai;
72
+ this.ai = new this.GoogleGenAICtor({
73
+ vertexai: true,
74
+ project: this.project,
75
+ location: this.location,
76
+ });
77
+ return this.ai;
78
+ }
79
+
80
+ async generate(
81
+ messages: ChatMessage[],
82
+ options?: ModelConfig & { tools?: ToolDefinition[] }
83
+ ): Promise<ModelResponse> {
84
+ const { systemInstruction, contents } = this.toGoogleMessages(messages);
85
+
86
+ const config: Record<string, unknown> = {};
87
+ if (options?.temperature !== undefined)
88
+ config.temperature = options.temperature;
89
+ if (options?.maxTokens !== undefined)
90
+ config.maxOutputTokens = options.maxTokens;
91
+ if (options?.topP !== undefined) config.topP = options.topP;
92
+ if (options?.stop) config.stopSequences = options.stop;
93
+
94
+ if (options?.responseFormat) {
95
+ config.responseMimeType = "application/json";
96
+ const rf = options.responseFormat;
97
+ if (
98
+ typeof rf === "object" &&
99
+ rf !== null &&
100
+ "type" in rf &&
101
+ rf.type === "json_schema" &&
102
+ "schema" in rf &&
103
+ rf.schema
104
+ ) {
105
+ config.responseSchema = this.cleanJsonSchema(
106
+ rf.schema as Record<string, unknown>
107
+ );
108
+ }
109
+ }
110
+
111
+ const params: Record<string, unknown> = {
112
+ model: this.modelId,
113
+ contents,
114
+ config,
115
+ };
116
+
117
+ if (systemInstruction) params.systemInstruction = systemInstruction;
118
+ if (options?.tools?.length) {
119
+ params.tools = [
120
+ { functionDeclarations: this.toGoogleTools(options.tools) },
121
+ ];
122
+ }
123
+
124
+ const client = this.getClient();
125
+ const response = await client.models.generateContent(params);
126
+ return this.normalizeResponse(response);
127
+ }
128
+
129
+ async *stream(
130
+ messages: ChatMessage[],
131
+ options?: ModelConfig & { tools?: ToolDefinition[] }
132
+ ): AsyncGenerator<StreamChunk> {
133
+ const { systemInstruction, contents } = this.toGoogleMessages(messages);
134
+
135
+ const config: Record<string, unknown> = {};
136
+ if (options?.temperature !== undefined)
137
+ config.temperature = options.temperature;
138
+ if (options?.maxTokens !== undefined)
139
+ config.maxOutputTokens = options.maxTokens;
140
+ if (options?.topP !== undefined) config.topP = options.topP;
141
+ if (options?.stop) config.stopSequences = options.stop;
142
+
143
+ const params: Record<string, unknown> = {
144
+ model: this.modelId,
145
+ contents,
146
+ config,
147
+ };
148
+
149
+ if (systemInstruction) params.systemInstruction = systemInstruction;
150
+ if (options?.tools?.length) {
151
+ params.tools = [
152
+ { functionDeclarations: this.toGoogleTools(options.tools) },
153
+ ];
154
+ }
155
+
156
+ const client = this.getClient();
157
+ const streamResult = await client.models.generateContentStream(params);
158
+
159
+ let toolCallCounter = 0;
160
+
161
+ for await (const chunk of streamResult) {
162
+ const candidate = chunk.candidates?.[0];
163
+ if (!candidate?.content?.parts) continue;
164
+
165
+ for (const part of candidate.content.parts) {
166
+ if (part.text) {
167
+ yield { type: "text", text: part.text };
168
+ }
169
+
170
+ if (part.functionCall) {
171
+ const id = `vertex_tc_${toolCallCounter++}`;
172
+ yield {
173
+ type: "tool_call_start",
174
+ toolCall: { id, name: part.functionCall.name },
175
+ };
176
+ yield {
177
+ type: "tool_call_delta",
178
+ toolCallId: id,
179
+ argumentsDelta: JSON.stringify(part.functionCall.args ?? {}),
180
+ };
181
+ yield { type: "tool_call_end", toolCallId: id };
182
+ }
183
+ }
184
+
185
+ if (candidate.finishReason) {
186
+ let finishReason = "stop";
187
+ if (
188
+ candidate.finishReason === "STOP" ||
189
+ candidate.finishReason === "END_TURN"
190
+ )
191
+ finishReason = "stop";
192
+ else if (candidate.finishReason === "MAX_TOKENS")
193
+ finishReason = "length";
194
+ else if (candidate.finishReason === "SAFETY")
195
+ finishReason = "content_filter";
196
+
197
+ const hasToolCalls = candidate.content?.parts?.some(
198
+ (p: any) => p.functionCall
199
+ );
200
+ if (hasToolCalls) finishReason = "tool_calls";
201
+
202
+ yield {
203
+ type: "finish",
204
+ finishReason,
205
+ usage: chunk.usageMetadata
206
+ ? {
207
+ promptTokens: chunk.usageMetadata.promptTokenCount ?? 0,
208
+ completionTokens:
209
+ chunk.usageMetadata.candidatesTokenCount ?? 0,
210
+ totalTokens: chunk.usageMetadata.totalTokenCount ?? 0,
211
+ }
212
+ : undefined,
213
+ };
214
+ }
215
+ }
216
+ }
217
+
218
+ // ── Message conversion (identical to GoogleProvider) ─────────────────────
219
+
220
+ private toGoogleMessages(messages: ChatMessage[]): {
221
+ systemInstruction: string | undefined;
222
+ contents: unknown[];
223
+ } {
224
+ let systemInstruction: string | undefined;
225
+ const contents: unknown[] = [];
226
+
227
+ for (const msg of messages) {
228
+ if (msg.role === "system") {
229
+ systemInstruction = getTextContent(msg.content) || undefined;
230
+ continue;
231
+ }
232
+
233
+ if (msg.role === "user") {
234
+ if (isMultiModal(msg.content)) {
235
+ contents.push({
236
+ role: "user",
237
+ parts: msg.content.map((p) => this.partToGoogle(p)),
238
+ });
239
+ } else {
240
+ contents.push({
241
+ role: "user",
242
+ parts: [{ text: msg.content ?? "" }],
243
+ });
244
+ }
245
+ continue;
246
+ }
247
+
248
+ if (msg.role === "assistant") {
249
+ const parts: unknown[] = [];
250
+ if (msg.content) parts.push({ text: msg.content });
251
+ if (msg.toolCalls) {
252
+ for (const tc of msg.toolCalls) {
253
+ parts.push({
254
+ functionCall: { name: tc.name, args: tc.arguments },
255
+ });
256
+ }
257
+ }
258
+ if (parts.length === 0) parts.push({ text: "" });
259
+ contents.push({ role: "model", parts });
260
+ continue;
261
+ }
262
+
263
+ if (msg.role === "tool") {
264
+ contents.push({
265
+ role: "function",
266
+ parts: [
267
+ {
268
+ functionResponse: {
269
+ name: msg.name ?? "unknown",
270
+ response: { result: msg.content ?? "" },
271
+ },
272
+ },
273
+ ],
274
+ });
275
+ continue;
276
+ }
277
+ }
278
+
279
+ return { systemInstruction, contents };
280
+ }
281
+
282
+ private partToGoogle(part: ContentPart): unknown {
283
+ switch (part.type) {
284
+ case "text":
285
+ return { text: part.text };
286
+ case "image":
287
+ case "audio":
288
+ case "file": {
289
+ const isUrl =
290
+ part.data.startsWith("http://") || part.data.startsWith("https://");
291
+ if (isUrl) {
292
+ return {
293
+ fileData: {
294
+ fileUri: part.data,
295
+ mimeType:
296
+ part.mimeType ??
297
+ (part.type === "image"
298
+ ? "image/png"
299
+ : "application/octet-stream"),
300
+ },
301
+ };
302
+ }
303
+ return {
304
+ inlineData: {
305
+ data: part.data,
306
+ mimeType:
307
+ part.mimeType ??
308
+ (part.type === "image"
309
+ ? "image/png"
310
+ : part.type === "audio"
311
+ ? "audio/mp3"
312
+ : "application/octet-stream"),
313
+ },
314
+ };
315
+ }
316
+ }
317
+ }
318
+
319
+ private toGoogleTools(tools: ToolDefinition[]): unknown[] {
320
+ return tools.map((t) => ({
321
+ name: t.name,
322
+ description: t.description,
323
+ parameters: t.parameters,
324
+ }));
325
+ }
326
+
327
+ private cleanJsonSchema(
328
+ schema: Record<string, unknown>
329
+ ): Record<string, unknown> {
330
+ const cleaned = { ...schema };
331
+ delete cleaned["$schema"];
332
+ delete cleaned["$ref"];
333
+ delete cleaned["additionalProperties"];
334
+
335
+ if (cleaned.properties && typeof cleaned.properties === "object") {
336
+ const props: Record<string, unknown> = {};
337
+ for (const [key, val] of Object.entries(
338
+ cleaned.properties as Record<string, unknown>
339
+ )) {
340
+ props[key] =
341
+ typeof val === "object" && val
342
+ ? this.cleanJsonSchema(val as Record<string, unknown>)
343
+ : val;
344
+ }
345
+ cleaned.properties = props;
346
+ }
347
+
348
+ if (cleaned.items && typeof cleaned.items === "object") {
349
+ cleaned.items = this.cleanJsonSchema(
350
+ cleaned.items as Record<string, unknown>
351
+ );
352
+ }
353
+
354
+ return cleaned;
355
+ }
356
+
357
+ private normalizeResponse(response: any): ModelResponse {
358
+ const candidate = response.candidates?.[0];
359
+ const parts = candidate?.content?.parts ?? [];
360
+
361
+ let textContent = "";
362
+ const toolCalls: ToolCall[] = [];
363
+ let toolCallCounter = 0;
364
+
365
+ for (const part of parts) {
366
+ if (part.text) textContent += part.text;
367
+ if (part.functionCall) {
368
+ toolCalls.push({
369
+ id: `vertex_tc_${toolCallCounter++}`,
370
+ name: part.functionCall.name,
371
+ arguments: part.functionCall.args ?? {},
372
+ });
373
+ }
374
+ }
375
+
376
+ const usage: TokenUsage = {
377
+ promptTokens: response.usageMetadata?.promptTokenCount ?? 0,
378
+ completionTokens: response.usageMetadata?.candidatesTokenCount ?? 0,
379
+ totalTokens: response.usageMetadata?.totalTokenCount ?? 0,
380
+ };
381
+
382
+ let finishReason: ModelResponse["finishReason"] = "stop";
383
+ if (toolCalls.length > 0) finishReason = "tool_calls";
384
+ else if (candidate?.finishReason === "MAX_TOKENS")
385
+ finishReason = "length";
386
+ else if (candidate?.finishReason === "SAFETY")
387
+ finishReason = "content_filter";
388
+
389
+ return {
390
+ message: {
391
+ role: "assistant",
392
+ content: textContent || null,
393
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
394
+ },
395
+ usage,
396
+ finishReason,
397
+ raw: response,
398
+ };
399
+ }
400
+ }
@@ -3,6 +3,7 @@ import { OpenAIProvider } from "./providers/openai.js";
3
3
  import { AnthropicProvider } from "./providers/anthropic.js";
4
4
  import { GoogleProvider } from "./providers/google.js";
5
5
  import { OllamaProvider } from "./providers/ollama.js";
6
+ import { VertexAIProvider } from "./providers/vertex.js";
6
7
 
7
8
  type ProviderFactory = (
8
9
  modelId: string,
@@ -88,3 +89,19 @@ export function ollama(
88
89
  ): ModelProvider {
89
90
  return registry.resolve("ollama", modelId, config);
90
91
  }
92
+
93
+ registry.register(
94
+ "vertex",
95
+ (modelId, config) =>
96
+ new VertexAIProvider(
97
+ modelId,
98
+ config as { project?: string; location?: string; credentials?: string }
99
+ )
100
+ );
101
+
102
+ export function vertex(
103
+ modelId: string,
104
+ config?: { project?: string; location?: string; credentials?: string }
105
+ ): ModelProvider {
106
+ return registry.resolve("vertex", modelId, config);
107
+ }