Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09b588326c | |||
| 024f14000e | |||
| 633ca5dad6 | |||
| fd71a528e9 |
28
README.md
28
README.md
@@ -1,4 +1,4 @@
|
|||||||
# @a2amcp/mcp
|
# a2amcp
|
||||||
|
|
||||||
MCP gateway for a2acloud agents. It runs locally as a stdio MCP server and
|
MCP gateway for a2acloud agents. It runs locally as a stdio MCP server and
|
||||||
exposes deployed A2A agents as tools to Claude Code, Cursor, and other MCP
|
exposes deployed A2A agents as tools to Claude Code, Cursor, and other MCP
|
||||||
@@ -7,9 +7,9 @@ clients.
|
|||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx -y @a2amcp/mcp login
|
npx -y a2amcp login
|
||||||
npx -y @a2amcp/mcp add <agent-name>
|
npx -y a2amcp add <agent-name>
|
||||||
npx -y @a2amcp/mcp doctor
|
npx -y a2amcp doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
Add it to your MCP client config:
|
Add it to your MCP client config:
|
||||||
@@ -19,7 +19,7 @@ Add it to your MCP client config:
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"a2a": {
|
"a2a": {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["-y", "@a2amcp/mcp"]
|
"args": ["-y", "a2amcp"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,14 +30,14 @@ Restart the client after adding or removing agents.
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
a2a-mcp login # save control-plane credentials
|
a2amcp login # save control-plane credentials
|
||||||
a2a-mcp whoami # show current account
|
a2amcp whoami # show current account
|
||||||
a2a-mcp agents # list agents visible to your account
|
a2amcp agents # list agents visible to your account
|
||||||
a2a-mcp add <name> # expose one agent through this gateway
|
a2amcp add <name> # expose one agent through this gateway
|
||||||
a2a-mcp list # list locally enabled agents
|
a2amcp list # list locally enabled agents
|
||||||
a2a-mcp remove <name> # stop exposing an agent
|
a2amcp remove <name> # stop exposing an agent
|
||||||
a2a-mcp doctor # probe enabled agents and count tools
|
a2amcp doctor # probe enabled agents and count tools
|
||||||
a2a-mcp logout # clear local credentials
|
a2amcp logout # clear local credentials
|
||||||
```
|
```
|
||||||
|
|
||||||
Running `a2a-mcp` with no command starts the MCP gateway over stdio.
|
Running `a2amcp` with no command starts the MCP gateway over stdio.
|
||||||
|
|||||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -1,19 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "@a2amcp/mcp",
|
"name": "a2amcp",
|
||||||
"version": "0.1.1",
|
"version": "0.1.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@a2amcp/mcp",
|
"name": "a2amcp",
|
||||||
"version": "0.1.1",
|
"version": "0.1.5",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
"commander": "^12.1.0"
|
"commander": "^12.1.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"a2a-mcp": "dist/cli.js"
|
"a2a-mcp": "dist/cli.js",
|
||||||
|
"a2amcp": "dist/cli.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.12.0",
|
"@types/node": "^20.12.0",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "@a2amcp/mcp",
|
"name": "a2amcp",
|
||||||
"version": "0.1.1",
|
"version": "0.1.5",
|
||||||
"description": "MCP gateway for a2acloud agents. Run as a local MCP server; expose any number of deployed A2A agents to Claude Code, Cursor, and other MCP clients.",
|
"description": "MCP gateway for a2acloud agents. Run as a local MCP server; expose any number of deployed A2A agents to Claude Code, Cursor, and other MCP clients.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
"a2amcp": "dist/cli.js",
|
||||||
"a2a-mcp": "dist/cli.js"
|
"a2a-mcp": "dist/cli.js"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
20
src/api.ts
20
src/api.ts
@@ -52,23 +52,21 @@ export class ControlPlaneClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
login(email: string, password: string) {
|
login(email: string, password: string) {
|
||||||
return this.request<{ access_token: string; user: { email: string } }>(
|
return this.request<{
|
||||||
"POST",
|
access_token: string;
|
||||||
"/v1/auth/login",
|
user: { id: number; email: string };
|
||||||
{ email, password },
|
}>("POST", "/v1/auth/login", { email, password });
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
signup(email: string, password: string) {
|
signup(email: string, password: string) {
|
||||||
return this.request<{ access_token: string; user: { email: string } }>(
|
return this.request<{
|
||||||
"POST",
|
access_token: string;
|
||||||
"/v1/auth/signup",
|
user: { id: number; email: string };
|
||||||
{ email, password },
|
}>("POST", "/v1/auth/signup", { email, password });
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
me() {
|
me() {
|
||||||
return this.request<{ email: string }>("GET", "/v1/me");
|
return this.request<{ id: number; email: string }>("GET", "/v1/me");
|
||||||
}
|
}
|
||||||
|
|
||||||
listAgents() {
|
listAgents() {
|
||||||
|
|||||||
29
src/cli.ts
29
src/cli.ts
@@ -1,15 +1,19 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* `a2a-mcp` — install once, expose any number of a2acloud agents to your MCP
|
* `a2amcp` — install once, expose any number of a2acloud agents to your MCP
|
||||||
* client (Claude Code, Cursor, etc.).
|
* client (Claude Code, Cursor, etc.).
|
||||||
*
|
*
|
||||||
* No-argument invocation runs the gateway over stdio. That's the form an MCP
|
* No-argument invocation runs the gateway over stdio. That's the form an MCP
|
||||||
* client launches. Subcommands manage the local agent list, auth, etc.
|
* client launches. Subcommands manage the local agent list, auth, etc.
|
||||||
*/
|
*/
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import readline from "node:readline/promises";
|
import readline from "node:readline/promises";
|
||||||
import { stdin as input, stdout as output, stderr } from "node:process";
|
import { stdin as input, stdout as output, stderr } from "node:process";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const pkg = require("../package.json") as { version: string };
|
||||||
|
|
||||||
import { ApiError, ControlPlaneClient } from "./api.js";
|
import { ApiError, ControlPlaneClient } from "./api.js";
|
||||||
import { addAgent, loadConfig, removeAgent } from "./config.js";
|
import { addAgent, loadConfig, removeAgent } from "./config.js";
|
||||||
import {
|
import {
|
||||||
@@ -24,9 +28,9 @@ import { runStdio } from "./gateway.js";
|
|||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
program
|
program
|
||||||
.name("a2a-mcp")
|
.name("a2amcp")
|
||||||
.description("MCP gateway for a2acloud agents.")
|
.description("MCP gateway for a2acloud agents.")
|
||||||
.version("0.1.0");
|
.version(pkg.version);
|
||||||
|
|
||||||
async function client(apiOverride?: string): Promise<ControlPlaneClient> {
|
async function client(apiOverride?: string): Promise<ControlPlaneClient> {
|
||||||
const creds = await loadCredentials();
|
const creds = await loadCredentials();
|
||||||
@@ -69,7 +73,7 @@ program
|
|||||||
try {
|
try {
|
||||||
await runStdio();
|
await runStdio();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
stderr.write(`a2a-mcp: ${(err as Error).message}\n`);
|
stderr.write(`a2amcp: ${(err as Error).message}\n`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -88,7 +92,14 @@ program
|
|||||||
const api = resolveApiUrl(opts.api);
|
const api = resolveApiUrl(opts.api);
|
||||||
try {
|
try {
|
||||||
const out = await new ControlPlaneClient(api, null).login(email, password);
|
const out = await new ControlPlaneClient(api, null).login(email, password);
|
||||||
await saveCredentials({ apiUrl: api, token: out.access_token, email: out.user.email });
|
const bucket = `user-${out.user.id}-files`;
|
||||||
|
await saveCredentials({
|
||||||
|
apiUrl: api,
|
||||||
|
token: out.access_token,
|
||||||
|
email: out.user.email,
|
||||||
|
userId: out.user.id,
|
||||||
|
bucket,
|
||||||
|
});
|
||||||
output.write(`logged in as ${out.user.email} @ ${api}\n`);
|
output.write(`logged in as ${out.user.email} @ ${api}\n`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fail(err instanceof ApiError ? err.message : (err as Error).message);
|
fail(err instanceof ApiError ? err.message : (err as Error).message);
|
||||||
@@ -108,7 +119,7 @@ program
|
|||||||
.description("Show the currently logged-in user.")
|
.description("Show the currently logged-in user.")
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
const creds = await loadCredentials();
|
const creds = await loadCredentials();
|
||||||
if (!creds) fail("not logged in (run `a2a-mcp login`)");
|
if (!creds) fail("not logged in (run `a2amcp login`)");
|
||||||
try {
|
try {
|
||||||
const me = await (await client()).me();
|
const me = await (await client()).me();
|
||||||
output.write(`${me.email} (${creds.apiUrl})\n`);
|
output.write(`${me.email} (${creds.apiUrl})\n`);
|
||||||
@@ -146,7 +157,7 @@ program
|
|||||||
.action(async () => {
|
.action(async () => {
|
||||||
const cfg = await loadConfig();
|
const cfg = await loadConfig();
|
||||||
if (cfg.agents.length === 0) {
|
if (cfg.agents.length === 0) {
|
||||||
output.write("(no agents added; try `a2a-mcp add <name>`)\n");
|
output.write("(no agents added; try `a2amcp add <name>`)\n");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const a of cfg.agents) output.write(` ${a.name} ${a.url}\n`);
|
for (const a of cfg.agents) output.write(` ${a.name} ${a.url}\n`);
|
||||||
@@ -229,7 +240,7 @@ program
|
|||||||
mcpServers: {
|
mcpServers: {
|
||||||
a2a: {
|
a2a: {
|
||||||
command: "npx",
|
command: "npx",
|
||||||
args: ["-y", "@a2amcp/mcp"],
|
args: ["-y", "a2amcp"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -237,6 +248,6 @@ program
|
|||||||
});
|
});
|
||||||
|
|
||||||
program.parseAsync(process.argv).catch((err) => {
|
program.parseAsync(process.argv).catch((err) => {
|
||||||
stderr.write(`a2a-mcp: ${(err as Error).message}\n`);
|
stderr.write(`a2amcp: ${(err as Error).message}\n`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Reader for ~/.a2a/credentials.json — same file the Python `a2a` CLI writes.
|
* Reader for ~/.a2a/credentials.json — same file the Python `a2a` CLI writes.
|
||||||
*
|
*
|
||||||
* We deliberately *only read* this file (and overwrite it on `a2a-mcp login`)
|
* We deliberately *only read* this file (and overwrite it on `a2amcp login`)
|
||||||
* so a single login flow is shared between the Python and Node tooling.
|
* so a single login flow is shared between the Python and Node tooling.
|
||||||
*/
|
*/
|
||||||
import { promises as fs } from "node:fs";
|
import { promises as fs } from "node:fs";
|
||||||
@@ -14,6 +14,8 @@ export interface Credentials {
|
|||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
token: string;
|
token: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
userId?: number;
|
||||||
|
bucket?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const credsDir = () => path.join(os.homedir(), ".a2a");
|
const credsDir = () => path.join(os.homedir(), ".a2a");
|
||||||
@@ -28,6 +30,8 @@ export async function loadCredentials(): Promise<Credentials | null> {
|
|||||||
apiUrl: data.api_url ?? DEFAULT_API_URL,
|
apiUrl: data.api_url ?? DEFAULT_API_URL,
|
||||||
token: data.token,
|
token: data.token,
|
||||||
email: data.email ?? "",
|
email: data.email ?? "",
|
||||||
|
userId: typeof data.user_id === "number" ? data.user_id : undefined,
|
||||||
|
bucket: typeof data.bucket === "string" ? data.bucket : undefined,
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.code === "ENOENT") return null;
|
if (err?.code === "ENOENT") return null;
|
||||||
@@ -37,11 +41,14 @@ export async function loadCredentials(): Promise<Credentials | null> {
|
|||||||
|
|
||||||
export async function saveCredentials(c: Credentials): Promise<void> {
|
export async function saveCredentials(c: Credentials): Promise<void> {
|
||||||
await fs.mkdir(credsDir(), { recursive: true });
|
await fs.mkdir(credsDir(), { recursive: true });
|
||||||
await fs.writeFile(
|
const out: Record<string, unknown> = {
|
||||||
credsFile(),
|
api_url: c.apiUrl,
|
||||||
JSON.stringify({ api_url: c.apiUrl, token: c.token, email: c.email }),
|
token: c.token,
|
||||||
{ mode: 0o600 },
|
email: c.email,
|
||||||
);
|
};
|
||||||
|
if (c.userId !== undefined) out.user_id = c.userId;
|
||||||
|
if (c.bucket !== undefined) out.bucket = c.bucket;
|
||||||
|
await fs.writeFile(credsFile(), JSON.stringify(out), { mode: 0o600 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearCredentials(): Promise<boolean> {
|
export async function clearCredentials(): Promise<boolean> {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* Naming: ``{agent}__{skill}``. Double underscore separator. Skill names
|
* Naming: ``{agent}__{skill}``. Double underscore separator. Skill names
|
||||||
* containing ``__`` are not supported.
|
* containing ``__`` are not supported.
|
||||||
*/
|
*/
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +18,9 @@ import { loadConfig, type EnabledAgent } from "./config.js";
|
|||||||
import { loadCredentials } from "./credentials.js";
|
import { loadCredentials } from "./credentials.js";
|
||||||
import { UpstreamAgent, UpstreamError, type UpstreamTool } from "./upstream.js";
|
import { UpstreamAgent, UpstreamError, type UpstreamTool } from "./upstream.js";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const pkg = require("../package.json") as { version: string };
|
||||||
|
|
||||||
export const SEP = "__";
|
export const SEP = "__";
|
||||||
|
|
||||||
export interface GatewayOptions {
|
export interface GatewayOptions {
|
||||||
@@ -35,20 +39,31 @@ export interface GatewayHandle {
|
|||||||
|
|
||||||
export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHandle> {
|
export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHandle> {
|
||||||
const cfg = opts.agents ?? (await loadConfig()).agents;
|
const cfg = opts.agents ?? (await loadConfig()).agents;
|
||||||
const token =
|
const creds = opts.token !== undefined ? null : await loadCredentials();
|
||||||
opts.token !== undefined
|
const token = opts.token !== undefined ? opts.token : creds?.token ?? null;
|
||||||
? opts.token
|
// Forward CP credentials + bucket as params._meta so upstream agents
|
||||||
: (await loadCredentials())?.token ?? null;
|
// can act on behalf of the caller (mirrors the platform-orchestrator
|
||||||
|
// call shape). When the user isn't logged in, _meta is omitted and
|
||||||
|
// upstream sees the same unauthenticated context as before.
|
||||||
|
const cpJwt = creds?.token ?? null;
|
||||||
|
const cpUrl = creds?.apiUrl ?? null;
|
||||||
|
const bucket = creds?.bucket ?? null;
|
||||||
|
|
||||||
const upstreams = new Map<string, UpstreamAgent>();
|
const upstreams = new Map<string, UpstreamAgent>();
|
||||||
for (const a of cfg) {
|
for (const a of cfg) {
|
||||||
upstreams.set(a.name, new UpstreamAgent({ name: a.name, url: a.url, token }));
|
upstreams.set(
|
||||||
|
a.name,
|
||||||
|
new UpstreamAgent({
|
||||||
|
name: a.name, url: a.url, token,
|
||||||
|
cpJwt, cpUrl, bucket,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const server =
|
const server =
|
||||||
opts.server ??
|
opts.server ??
|
||||||
new Server(
|
new Server(
|
||||||
{ name: "a2a-mcp", version: "0.1.0" },
|
{ name: "a2amcp", version: pkg.version },
|
||||||
{ capabilities: { tools: { listChanged: false } } },
|
{ capabilities: { tools: { listChanged: false } } },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -70,9 +85,9 @@ export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHa
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Silently skip an upstream that's down — surfacing an error here
|
// Silently skip an upstream that's down — surfacing an error here
|
||||||
// would break the client's whole tool list. The user can debug via
|
// would break the client's whole tool list. The user can debug via
|
||||||
// `a2a-mcp doctor`.
|
// `a2amcp doctor`.
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`a2a-mcp: skipping ${u.name}: ${(err as Error).message}\n`,
|
`a2amcp: skipping ${u.name}: ${(err as Error).message}\n`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
113
src/upstream.ts
113
src/upstream.ts
@@ -12,6 +12,18 @@ export interface UpstreamConfig {
|
|||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
/** Forwarded to the upstream as ``params._meta.cp_jwt`` on tools/call.
|
||||||
|
* Lets the upstream agent act on the caller's behalf (file/agent CRUD
|
||||||
|
* on /v1/me/*) — same path the platform orchestrator uses. */
|
||||||
|
cpJwt?: string | null;
|
||||||
|
/** Paired with ``cpJwt``. The upstream agent uses this as the base URL
|
||||||
|
* when calling back into /v1/me/*. */
|
||||||
|
cpUrl?: string | null;
|
||||||
|
/** User's MinIO bucket (``user-<id>-files``). Forwarded as
|
||||||
|
* ``params._meta.bucket`` so agents that touch the workspace
|
||||||
|
* (agent-builder, file tools) get a context whose
|
||||||
|
* ``ctx.workspace.bucket`` resolves to the right bucket. */
|
||||||
|
bucket?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpstreamTool {
|
export interface UpstreamTool {
|
||||||
@@ -88,9 +100,104 @@ export class UpstreamAgent {
|
|||||||
name: string,
|
name: string,
|
||||||
arguments_: Record<string, unknown>,
|
arguments_: Record<string, unknown>,
|
||||||
): Promise<UpstreamCallResult> {
|
): Promise<UpstreamCallResult> {
|
||||||
return this.rpc<UpstreamCallResult>("tools/call", {
|
const meta: Record<string, unknown> = {};
|
||||||
name,
|
if (this.cfg.cpJwt) meta.cp_jwt = this.cfg.cpJwt;
|
||||||
arguments: arguments_,
|
if (this.cfg.cpUrl) meta.cp_url = this.cfg.cpUrl;
|
||||||
|
if (this.cfg.bucket) meta.bucket = this.cfg.bucket;
|
||||||
|
const params: Record<string, unknown> = { name, arguments: arguments_ };
|
||||||
|
if (Object.keys(meta).length > 0) params._meta = meta;
|
||||||
|
return this.streamingCallTool(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tools/call always negotiates SSE on the wire — upstream agents can
|
||||||
|
* take minutes (agent-builder.build runs an LLM loop + sandbox tests +
|
||||||
|
* a deploy + a wait-for-live-card poll), and a plain JSON POST gets
|
||||||
|
* killed by ingress idle timeouts (~60s default on traefik). The
|
||||||
|
* upstream's SSE response interleaves any elicitation/create requests
|
||||||
|
* and a final tools/call result; here we drain the stream and return
|
||||||
|
* the final result. (Elicit relaying to the MCP client is not wired
|
||||||
|
* yet — any elicit that fires will time out on the upstream side and
|
||||||
|
* the skill will surface a soft error.)
|
||||||
|
*/
|
||||||
|
private async streamingCallTool(
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): Promise<UpstreamCallResult> {
|
||||||
|
const id = _nextId++;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"content-type": "application/json",
|
||||||
|
accept: "text/event-stream, application/json",
|
||||||
|
};
|
||||||
|
if (this.cfg.token) headers["authorization"] = `Bearer ${this.cfg.token}`;
|
||||||
|
|
||||||
|
const resp = await fetch(`${this.cfg.url}/mcp`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: JSON_RPC, id, method: "tools/call", params,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text().catch(() => "");
|
||||||
|
throw new UpstreamError(this.cfg.name, resp.status, text || resp.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctype = resp.headers.get("content-type") || "";
|
||||||
|
if (!ctype.includes("text/event-stream")) {
|
||||||
|
// Server fell back to JSON (older a2a-pack or non-SSE upstream).
|
||||||
|
const env: any = await resp.json();
|
||||||
|
if (env?.error) {
|
||||||
|
throw new UpstreamError(
|
||||||
|
this.cfg.name, 500,
|
||||||
|
`${env.error.code}: ${env.error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return env?.result as UpstreamCallResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.body) {
|
||||||
|
throw new UpstreamError(this.cfg.name, 502, "empty stream body");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = resp.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = "";
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (value) buf += decoder.decode(value, { stream: true });
|
||||||
|
let sep = buf.indexOf("\n\n");
|
||||||
|
while (sep !== -1) {
|
||||||
|
const frame = buf.slice(0, sep);
|
||||||
|
buf = buf.slice(sep + 2);
|
||||||
|
const dataLines: string[] = [];
|
||||||
|
for (const ln of frame.split("\n")) {
|
||||||
|
if (ln.startsWith("data:")) dataLines.push(ln.slice(5).trimStart());
|
||||||
|
}
|
||||||
|
if (dataLines.length > 0) {
|
||||||
|
const raw = dataLines.join("\n");
|
||||||
|
let msg: any;
|
||||||
|
try { msg = JSON.parse(raw); } catch { msg = null; }
|
||||||
|
if (msg && msg.id === id) {
|
||||||
|
if (msg.error) {
|
||||||
|
throw new UpstreamError(
|
||||||
|
this.cfg.name, 500,
|
||||||
|
`${msg.error.code}: ${msg.error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return msg.result as UpstreamCallResult;
|
||||||
|
}
|
||||||
|
// else: server→client request (elicitation/create) or another
|
||||||
|
// frame; ignore — elicit relaying not wired yet.
|
||||||
|
}
|
||||||
|
sep = buf.indexOf("\n\n");
|
||||||
|
}
|
||||||
|
if (done) break;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { reader.releaseLock(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
throw new UpstreamError(this.cfg.name, 502, "stream ended without result");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user