Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e034412ca | |||
| bbc7f593b1 | |||
| b0dd1fa28d | |||
| 91c7bf9c57 | |||
| 4ff51157c0 | |||
| 2fd97d8d09 | |||
| 45e12324df | |||
| 09b588326c | |||
| 024f14000e | |||
| 633ca5dad6 | |||
| fd71a528e9 | |||
| f0f6fe9672 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.codegraph/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
|
||||
59
README.md
59
README.md
@@ -1,15 +1,30 @@
|
||||
# @a2a/mcp
|
||||
# a2amcp
|
||||
|
||||
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
|
||||
clients.
|
||||
|
||||
This package is the **local stdio gateway**. It is different from the hosted
|
||||
OAuth MCP connector endpoints used by ChatGPT/Claude web connectors:
|
||||
|
||||
```text
|
||||
Local stdio gateway: npx -y a2amcp
|
||||
Remote standard MCP: https://<agent>.a2acloud.io/mcp
|
||||
Remote connector MCP: https://<agent>.a2acloud.io/connector-mcp
|
||||
Remote orchestrator MCP: https://api.a2acloud.io/connector-mcp
|
||||
```
|
||||
|
||||
Use `a2amcp` for editor clients that launch a local MCP server process. Use the
|
||||
remote `/connector-mcp` URL for hosted connector UIs that need OAuth login,
|
||||
Dynamic Client Registration, async job polling, and structured approval/input
|
||||
interrupts.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
npx -y @a2a/mcp login
|
||||
npx -y @a2a/mcp add <agent-name>
|
||||
npx -y @a2a/mcp doctor
|
||||
npx -y a2amcp login
|
||||
npx -y a2amcp add <agent-name>
|
||||
npx -y a2amcp doctor
|
||||
```
|
||||
|
||||
Add it to your MCP client config:
|
||||
@@ -19,7 +34,7 @@ Add it to your MCP client config:
|
||||
"mcpServers": {
|
||||
"a2a": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@a2a/mcp"]
|
||||
"args": ["-y", "a2amcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,14 +45,30 @@ Restart the client after adding or removing agents.
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
a2a-mcp login # save control-plane credentials
|
||||
a2a-mcp whoami # show current account
|
||||
a2a-mcp agents # list agents visible to your account
|
||||
a2a-mcp add <name> # expose one agent through this gateway
|
||||
a2a-mcp list # list locally enabled agents
|
||||
a2a-mcp remove <name> # stop exposing an agent
|
||||
a2a-mcp doctor # probe enabled agents and count tools
|
||||
a2a-mcp logout # clear local credentials
|
||||
a2amcp login # save control-plane credentials
|
||||
a2amcp whoami # show current account
|
||||
a2amcp agents # list agents visible to your account
|
||||
a2amcp add <name> # expose one agent through this gateway
|
||||
a2amcp list # list locally enabled agents
|
||||
a2amcp remove <name> # stop exposing an agent
|
||||
a2amcp doctor # probe enabled agents and count tools
|
||||
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.
|
||||
|
||||
## Hosted OAuth Connectors
|
||||
|
||||
ChatGPT and Claude-style hosted connectors should not run this stdio gateway.
|
||||
Configure them directly with the remote connector URL:
|
||||
|
||||
```text
|
||||
https://api.a2acloud.io/connector-mcp
|
||||
https://<agent>.a2acloud.io/connector-mcp
|
||||
```
|
||||
|
||||
Choose OAuth authentication. The server advertises protected-resource metadata,
|
||||
the client dynamically registers with Keycloak, and the browser consent flow
|
||||
issues tokens scoped to MCP access. Long-running connector calls return a
|
||||
`job_id`; the client should poll `chat_result` on the orchestrator connector or
|
||||
`job_result` on a leaf-agent connector.
|
||||
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -1,19 +1,20 @@
|
||||
{
|
||||
"name": "@a2a/mcp",
|
||||
"version": "0.1.0",
|
||||
"name": "a2amcp",
|
||||
"version": "0.1.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@a2a/mcp",
|
||||
"version": "0.1.0",
|
||||
"name": "a2amcp",
|
||||
"version": "0.1.7",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"commander": "^12.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"a2a-mcp": "dist/cli.js"
|
||||
"a2a-mcp": "dist/cli.js",
|
||||
"a2amcp": "dist/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.0",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "@a2a/mcp",
|
||||
"version": "0.1.0",
|
||||
"name": "a2amcp",
|
||||
"version": "0.1.7",
|
||||
"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",
|
||||
"bin": {
|
||||
"a2amcp": "dist/cli.js",
|
||||
"a2a-mcp": "dist/cli.js"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
|
||||
21
src/api.ts
21
src/api.ts
@@ -15,6 +15,7 @@ export interface AgentRow {
|
||||
status: string;
|
||||
url?: string;
|
||||
description?: string;
|
||||
card?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class ControlPlaneClient {
|
||||
@@ -52,23 +53,21 @@ export class ControlPlaneClient {
|
||||
}
|
||||
|
||||
login(email: string, password: string) {
|
||||
return this.request<{ access_token: string; user: { email: string } }>(
|
||||
"POST",
|
||||
"/v1/auth/login",
|
||||
{ email, password },
|
||||
);
|
||||
return this.request<{
|
||||
access_token: string;
|
||||
user: { id: number; email: string };
|
||||
}>("POST", "/v1/auth/login", { email, password });
|
||||
}
|
||||
|
||||
signup(email: string, password: string) {
|
||||
return this.request<{ access_token: string; user: { email: string } }>(
|
||||
"POST",
|
||||
"/v1/auth/signup",
|
||||
{ email, password },
|
||||
);
|
||||
return this.request<{
|
||||
access_token: string;
|
||||
user: { id: number; email: string };
|
||||
}>("POST", "/v1/auth/signup", { email, password });
|
||||
}
|
||||
|
||||
me() {
|
||||
return this.request<{ email: string }>("GET", "/v1/me");
|
||||
return this.request<{ id: number; email: string }>("GET", "/v1/me");
|
||||
}
|
||||
|
||||
listAgents() {
|
||||
|
||||
70
src/cli.ts
70
src/cli.ts
@@ -1,15 +1,19 @@
|
||||
#!/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.).
|
||||
*
|
||||
* No-argument invocation runs the gateway over stdio. That's the form an MCP
|
||||
* client launches. Subcommands manage the local agent list, auth, etc.
|
||||
*/
|
||||
import { createRequire } from "node:module";
|
||||
import { Command } from "commander";
|
||||
import readline from "node:readline/promises";
|
||||
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 { addAgent, loadConfig, removeAgent } from "./config.js";
|
||||
import {
|
||||
@@ -24,9 +28,9 @@ import { runStdio } from "./gateway.js";
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name("a2a-mcp")
|
||||
.name("a2amcp")
|
||||
.description("MCP gateway for a2acloud agents.")
|
||||
.version("0.1.0");
|
||||
.version(pkg.version);
|
||||
|
||||
async function client(apiOverride?: string): Promise<ControlPlaneClient> {
|
||||
const creds = await loadCredentials();
|
||||
@@ -39,6 +43,30 @@ function fail(msg: string, code = 1): never {
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function normalizeMcpPath(path: unknown): string {
|
||||
if (typeof path !== "string" || !path.trim()) return "/mcp";
|
||||
const trimmed = path.trim();
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
|
||||
function mcpTargetForAgent(
|
||||
row: { name: string; url?: string; card?: Record<string, any> },
|
||||
apiUrl: string,
|
||||
): { url?: string; mcpPath: string } {
|
||||
const imported = row.card?.capabilities?.a2a_cloud_import;
|
||||
const mcp = imported?.mcp;
|
||||
if (mcp?.mode === "generated") {
|
||||
return {
|
||||
url: `${apiUrl.replace(/\/+$/, "")}/v1/agents/${encodeURIComponent(row.name)}`,
|
||||
mcpPath: normalizeMcpPath(mcp.path),
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: mcp?.base_url || row.url,
|
||||
mcpPath: normalizeMcpPath(mcp?.path || row.card?.mcp_endpoint),
|
||||
};
|
||||
}
|
||||
|
||||
async function prompt(label: string, opts: { hidden?: boolean } = {}): Promise<string> {
|
||||
const rl = readline.createInterface({ input, output, terminal: true });
|
||||
try {
|
||||
@@ -69,7 +97,7 @@ program
|
||||
try {
|
||||
await runStdio();
|
||||
} catch (err) {
|
||||
stderr.write(`a2a-mcp: ${(err as Error).message}\n`);
|
||||
stderr.write(`a2amcp: ${(err as Error).message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -88,7 +116,14 @@ program
|
||||
const api = resolveApiUrl(opts.api);
|
||||
try {
|
||||
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`);
|
||||
} catch (err) {
|
||||
fail(err instanceof ApiError ? err.message : (err as Error).message);
|
||||
@@ -108,7 +143,7 @@ program
|
||||
.description("Show the currently logged-in user.")
|
||||
.action(async () => {
|
||||
const creds = await loadCredentials();
|
||||
if (!creds) fail("not logged in (run `a2a-mcp login`)");
|
||||
if (!creds) fail("not logged in (run `a2amcp login`)");
|
||||
try {
|
||||
const me = await (await client()).me();
|
||||
output.write(`${me.email} (${creds.apiUrl})\n`);
|
||||
@@ -146,10 +181,12 @@ program
|
||||
.action(async () => {
|
||||
const cfg = await loadConfig();
|
||||
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;
|
||||
}
|
||||
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}${a.mcpPath ?? "/mcp"}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
@@ -160,11 +197,16 @@ program
|
||||
.action(
|
||||
async (name: string, opts: { url?: string; api?: string }) => {
|
||||
let url = opts.url;
|
||||
let mcpPath = "/mcp";
|
||||
let description: string | undefined;
|
||||
if (!url) {
|
||||
try {
|
||||
const row = await (await client(opts.api)).getAgent(name);
|
||||
url = row.url;
|
||||
const creds = await loadCredentials();
|
||||
const apiUrl = resolveApiUrl(opts.api ?? creds?.apiUrl);
|
||||
const row = await new ControlPlaneClient(apiUrl, creds?.token ?? null).getAgent(name);
|
||||
const target = mcpTargetForAgent(row, apiUrl);
|
||||
url = target.url;
|
||||
mcpPath = target.mcpPath;
|
||||
description = row.description;
|
||||
} catch (err) {
|
||||
fail(err instanceof ApiError ? err.message : (err as Error).message);
|
||||
@@ -174,10 +216,11 @@ program
|
||||
await addAgent({
|
||||
name,
|
||||
url,
|
||||
mcpPath,
|
||||
description,
|
||||
addedAt: new Date().toISOString(),
|
||||
});
|
||||
output.write(`added ${name} -> ${url}\n`);
|
||||
output.write(`added ${name} -> ${url.replace(/\/+$/, "")}${mcpPath}\n`);
|
||||
output.write("restart your MCP client to pick up the new tools.\n");
|
||||
},
|
||||
);
|
||||
@@ -208,6 +251,7 @@ program
|
||||
const u = new UpstreamAgent({
|
||||
name: a.name,
|
||||
url: a.url,
|
||||
mcpPath: a.mcpPath,
|
||||
token: creds?.token ?? null,
|
||||
});
|
||||
try {
|
||||
@@ -229,7 +273,7 @@ program
|
||||
mcpServers: {
|
||||
a2a: {
|
||||
command: "npx",
|
||||
args: ["-y", "@a2a/mcp"],
|
||||
args: ["-y", "a2amcp"],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -237,6 +281,6 @@ program
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import path from "node:path";
|
||||
export interface EnabledAgent {
|
||||
name: string;
|
||||
url: string;
|
||||
mcpPath?: string;
|
||||
description?: string;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { promises as fs } from "node:fs";
|
||||
@@ -14,6 +14,8 @@ export interface Credentials {
|
||||
apiUrl: string;
|
||||
token: string;
|
||||
email: string;
|
||||
userId?: number;
|
||||
bucket?: string;
|
||||
}
|
||||
|
||||
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,
|
||||
token: data.token,
|
||||
email: data.email ?? "",
|
||||
userId: typeof data.user_id === "number" ? data.user_id : undefined,
|
||||
bucket: typeof data.bucket === "string" ? data.bucket : undefined,
|
||||
};
|
||||
} catch (err: any) {
|
||||
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> {
|
||||
await fs.mkdir(credsDir(), { recursive: true });
|
||||
await fs.writeFile(
|
||||
credsFile(),
|
||||
JSON.stringify({ api_url: c.apiUrl, token: c.token, email: c.email }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const out: Record<string, unknown> = {
|
||||
api_url: c.apiUrl,
|
||||
token: c.token,
|
||||
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> {
|
||||
|
||||
@@ -6,17 +6,23 @@
|
||||
* Naming: ``{agent}__{skill}``. Double underscore separator. Skill names
|
||||
* containing ``__`` are not supported.
|
||||
*/
|
||||
import { createRequire } from "node:module";
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ElicitResultSchema,
|
||||
ListToolsRequestSchema,
|
||||
ResultSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import { loadConfig, type EnabledAgent } from "./config.js";
|
||||
import { loadCredentials } from "./credentials.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 interface GatewayOptions {
|
||||
@@ -24,6 +30,10 @@ export interface GatewayOptions {
|
||||
agents?: EnabledAgent[];
|
||||
/** Override the bearer token (otherwise read from ~/.a2a/credentials.json). */
|
||||
token?: string | null;
|
||||
/** Test/advanced override for one-shot upstream JSON-RPC calls. */
|
||||
upstreamRequestTimeoutMs?: number;
|
||||
/** Test/advanced override for idle SSE upstream tools/call streams. */
|
||||
upstreamStreamIdleTimeoutMs?: number;
|
||||
/** Optional override for the underlying MCP Server (tests). */
|
||||
server?: Server;
|
||||
}
|
||||
@@ -35,20 +45,33 @@ export interface GatewayHandle {
|
||||
|
||||
export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHandle> {
|
||||
const cfg = opts.agents ?? (await loadConfig()).agents;
|
||||
const token =
|
||||
opts.token !== undefined
|
||||
? opts.token
|
||||
: (await loadCredentials())?.token ?? null;
|
||||
const creds = opts.token !== undefined ? null : await loadCredentials();
|
||||
const token = opts.token !== undefined ? opts.token : creds?.token ?? null;
|
||||
// Forward CP credentials + bucket as params._meta so upstream agents
|
||||
// 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>();
|
||||
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, mcpPath: a.mcpPath, token,
|
||||
cpJwt, cpUrl, bucket,
|
||||
requestTimeoutMs: opts.upstreamRequestTimeoutMs,
|
||||
streamIdleTimeoutMs: opts.upstreamStreamIdleTimeoutMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const server =
|
||||
opts.server ??
|
||||
new Server(
|
||||
{ name: "a2a-mcp", version: "0.1.0" },
|
||||
{ name: "a2amcp", version: pkg.version },
|
||||
{ capabilities: { tools: { listChanged: false } } },
|
||||
);
|
||||
|
||||
@@ -70,9 +93,9 @@ export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHa
|
||||
} catch (err) {
|
||||
// Silently skip an upstream that's down — surfacing an error here
|
||||
// would break the client's whole tool list. The user can debug via
|
||||
// `a2a-mcp doctor`.
|
||||
// `a2amcp doctor`.
|
||||
process.stderr.write(
|
||||
`a2a-mcp: skipping ${u.name}: ${(err as Error).message}\n`,
|
||||
`a2amcp: skipping ${u.name}: ${(err as Error).message}\n`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
@@ -80,7 +103,7 @@ export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHa
|
||||
return { tools };
|
||||
});
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
||||
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
|
||||
const fullName = req.params.name;
|
||||
const idx = fullName.indexOf(SEP);
|
||||
if (idx < 0) {
|
||||
@@ -92,10 +115,42 @@ export async function buildGateway(opts: GatewayOptions = {}): Promise<GatewayHa
|
||||
if (!upstream) {
|
||||
return _softError(`unknown agent: ${agentName}`) as any;
|
||||
}
|
||||
// Honor the client's progressToken if it supplied one; else fall
|
||||
// back to the request id. Either way, when upstream emits
|
||||
// notifications/progress we forward them via the SDK's
|
||||
// sendNotification so clients like Claude Code reset their idle
|
||||
// tool-call timer and don't abort long builds.
|
||||
const clientToken = (req.params._meta as any)?.progressToken as
|
||||
| string
|
||||
| number
|
||||
| undefined;
|
||||
const progressToken = clientToken ?? (extra.requestId as string | number);
|
||||
try {
|
||||
const result = await upstream.callTool(
|
||||
toolName,
|
||||
(req.params.arguments as Record<string, unknown>) ?? {},
|
||||
{
|
||||
progressToken,
|
||||
onNotification: async (notif) => {
|
||||
try {
|
||||
await extra.sendNotification(notif as any);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
},
|
||||
onRequest: async (upstreamReq) => {
|
||||
const schema = upstreamReq.method === "elicitation/create"
|
||||
? ElicitResultSchema
|
||||
: ResultSchema;
|
||||
return extra.sendRequest(
|
||||
{
|
||||
method: upstreamReq.method,
|
||||
params: upstreamReq.params,
|
||||
} as any,
|
||||
schema as any,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
// Pass through the upstream's CallToolResult as-is. Upstreams already
|
||||
// populate content / structuredContent / isError per the MCP spec.
|
||||
|
||||
298
src/upstream.ts
298
src/upstream.ts
@@ -7,11 +7,28 @@
|
||||
* header without coupling to a transport class.
|
||||
*/
|
||||
const JSON_RPC = "2.0";
|
||||
const DEFAULT_RPC_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120_000;
|
||||
|
||||
export interface UpstreamConfig {
|
||||
name: string;
|
||||
url: string;
|
||||
mcpPath?: string;
|
||||
token: string | null;
|
||||
requestTimeoutMs?: number;
|
||||
streamIdleTimeoutMs?: number;
|
||||
/** 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 {
|
||||
@@ -27,6 +44,12 @@ export interface UpstreamCallResult {
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export interface UpstreamServerRequest {
|
||||
id: string | number;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class UpstreamError extends Error {
|
||||
constructor(
|
||||
public agent: string,
|
||||
@@ -40,27 +63,48 @@ export class UpstreamError extends Error {
|
||||
|
||||
let _nextId = 1;
|
||||
|
||||
function normalizeMcpPath(path: string | undefined): string {
|
||||
if (!path) return "/mcp";
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed) return "/mcp";
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
|
||||
export class UpstreamAgent {
|
||||
constructor(private cfg: UpstreamConfig) {
|
||||
this.cfg.url = cfg.url.replace(/\/+$/, "");
|
||||
this.cfg.mcpPath = normalizeMcpPath(cfg.mcpPath);
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.cfg.name;
|
||||
}
|
||||
|
||||
private mcpUrl(): string {
|
||||
return `${this.cfg.url}${this.cfg.mcpPath ?? "/mcp"}`;
|
||||
}
|
||||
|
||||
private async rpc<T>(method: string, params: unknown): Promise<T> {
|
||||
const id = _nextId++;
|
||||
const timeoutMs = this.cfg.requestTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS;
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json",
|
||||
};
|
||||
if (this.cfg.token) headers["authorization"] = `Bearer ${this.cfg.token}`;
|
||||
|
||||
const resp = await fetch(`${this.cfg.url}/mcp`, {
|
||||
try {
|
||||
const resp = await fetch(this.mcpUrl(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ jsonrpc: JSON_RPC, id, method, params }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
@@ -77,6 +121,18 @@ export class UpstreamAgent {
|
||||
);
|
||||
}
|
||||
return env?.result as T;
|
||||
} catch (err) {
|
||||
if (timedOut) {
|
||||
throw new UpstreamError(
|
||||
this.cfg.name,
|
||||
504,
|
||||
`request timed out after ${timeoutMs}ms`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async listTools(): Promise<UpstreamTool[]> {
|
||||
@@ -87,10 +143,244 @@ export class UpstreamAgent {
|
||||
async callTool(
|
||||
name: string,
|
||||
arguments_: Record<string, unknown>,
|
||||
opts: {
|
||||
onNotification?: (notif: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}) => void | Promise<void>;
|
||||
onRequest?: (req: UpstreamServerRequest) => unknown | Promise<unknown>;
|
||||
progressToken?: string | number;
|
||||
} = {},
|
||||
): Promise<UpstreamCallResult> {
|
||||
return this.rpc<UpstreamCallResult>("tools/call", {
|
||||
name,
|
||||
arguments: arguments_,
|
||||
const meta: Record<string, unknown> = {};
|
||||
if (this.cfg.cpJwt) meta.cp_jwt = this.cfg.cpJwt;
|
||||
if (this.cfg.cpUrl) meta.cp_url = this.cfg.cpUrl;
|
||||
if (this.cfg.bucket) meta.bucket = this.cfg.bucket;
|
||||
if (opts.progressToken !== undefined) meta.progressToken = opts.progressToken;
|
||||
const params: Record<string, unknown> = { name, arguments: arguments_ };
|
||||
if (Object.keys(meta).length > 0) params._meta = meta;
|
||||
return this.streamingCallTool(params, {
|
||||
onNotification: opts.onNotification,
|
||||
onRequest: opts.onRequest,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 relay server-to-client
|
||||
* requests through the MCP client and return the final result.
|
||||
*/
|
||||
private async streamingCallTool(
|
||||
params: Record<string, unknown>,
|
||||
handlers: {
|
||||
onNotification?: (notif: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}) => void | Promise<void>;
|
||||
onRequest?: (req: UpstreamServerRequest) => unknown | Promise<unknown>;
|
||||
} = {},
|
||||
): Promise<UpstreamCallResult> {
|
||||
const id = _nextId++;
|
||||
const idleTimeoutMs =
|
||||
this.cfg.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const resetIdleTimer = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, idleTimeoutMs);
|
||||
};
|
||||
resetIdleTimer();
|
||||
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}`;
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(this.mcpUrl(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
jsonrpc: JSON_RPC, id, method: "tools/call", params,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(idleTimer);
|
||||
if (timedOut) {
|
||||
throw new UpstreamError(
|
||||
this.cfg.name,
|
||||
504,
|
||||
`stream idle timeout after ${idleTimeoutMs}ms`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
resetIdleTimer();
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => "");
|
||||
clearTimeout(idleTimer);
|
||||
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) {
|
||||
clearTimeout(idleTimer);
|
||||
throw new UpstreamError(
|
||||
this.cfg.name, 500,
|
||||
`${env.error.code}: ${env.error.message}`,
|
||||
);
|
||||
}
|
||||
clearTimeout(idleTimer);
|
||||
return env?.result as UpstreamCallResult;
|
||||
}
|
||||
|
||||
const upstreamSessionId = resp.headers.get("mcp-session-id");
|
||||
|
||||
if (!resp.body) {
|
||||
clearTimeout(idleTimer);
|
||||
throw new UpstreamError(this.cfg.name, 502, "empty stream body");
|
||||
}
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
try {
|
||||
while (true) {
|
||||
let done: boolean;
|
||||
let value: Uint8Array | undefined;
|
||||
try {
|
||||
({ done, value } = await reader.read());
|
||||
} catch (err) {
|
||||
if (timedOut) {
|
||||
throw new UpstreamError(
|
||||
this.cfg.name,
|
||||
504,
|
||||
`stream idle timeout after ${idleTimeoutMs}ms`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (value) {
|
||||
resetIdleTimer();
|
||||
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");
|
||||
if (raw === "[DONE]") {
|
||||
sep = buf.indexOf("\n\n");
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
// Otherwise it's a server→client message. Notifications
|
||||
// (no id) are best-effort. Requests (has id, has method), such
|
||||
// as elicitation/create, must be relayed and answered upstream.
|
||||
if (msg && typeof msg.method === "string" && !("id" in msg) && handlers.onNotification) {
|
||||
try {
|
||||
await handlers.onNotification({
|
||||
method: msg.method,
|
||||
params: msg.params as Record<string, unknown> | undefined,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort; never break the stream on a notify error.
|
||||
}
|
||||
} else if (msg && typeof msg.method === "string" && "id" in msg) {
|
||||
await this.relayServerRequest(upstreamSessionId, {
|
||||
id: msg.id as string | number,
|
||||
method: msg.method,
|
||||
params: msg.params as Record<string, unknown> | undefined,
|
||||
}, handlers.onRequest);
|
||||
resetIdleTimer();
|
||||
}
|
||||
}
|
||||
sep = buf.indexOf("\n\n");
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(idleTimer);
|
||||
try { reader.releaseLock(); } catch { /* ignore */ }
|
||||
}
|
||||
throw new UpstreamError(this.cfg.name, 502, "stream ended without result");
|
||||
}
|
||||
|
||||
private async relayServerRequest(
|
||||
upstreamSessionId: string | null,
|
||||
req: UpstreamServerRequest,
|
||||
onRequest?: (req: UpstreamServerRequest) => unknown | Promise<unknown>,
|
||||
): Promise<void> {
|
||||
if (!upstreamSessionId) {
|
||||
throw new UpstreamError(
|
||||
this.cfg.name,
|
||||
502,
|
||||
`upstream request ${req.method} missing Mcp-Session-Id`,
|
||||
);
|
||||
}
|
||||
let envelope: Record<string, unknown>;
|
||||
try {
|
||||
if (!onRequest) throw new Error(`no client request relay for ${req.method}`);
|
||||
const result = await onRequest(req);
|
||||
envelope = { jsonrpc: JSON_RPC, id: req.id, result };
|
||||
} catch (err) {
|
||||
envelope = {
|
||||
jsonrpc: JSON_RPC,
|
||||
id: req.id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json",
|
||||
"mcp-session-id": upstreamSessionId,
|
||||
};
|
||||
if (this.cfg.token) headers.authorization = `Bearer ${this.cfg.token}`;
|
||||
const resp = await fetch(this.mcpUrl(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(envelope),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => "");
|
||||
throw new UpstreamError(
|
||||
this.cfg.name,
|
||||
resp.status,
|
||||
text || `failed to deliver response for ${req.method}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import { buildGateway, SEP } from "../src/gateway.js";
|
||||
import { buildGateway, SEP, type GatewayOptions } from "../src/gateway.js";
|
||||
import type { EnabledAgent } from "../src/config.js";
|
||||
|
||||
/**
|
||||
@@ -70,25 +70,65 @@ function startFakeAgent(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
function startSseAgent(opts: {
|
||||
onCall: (body: any, res: http.ServerResponse) => void;
|
||||
}): Promise<{ url: string; close: () => Promise<void>; requests: any[] }> {
|
||||
const requests: any[] = [];
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method !== "POST" || req.url !== "/mcp") {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (c) => chunks.push(c));
|
||||
req.on("end", () => {
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
requests.push({ body });
|
||||
if (body.method !== "tools/call") {
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify({ jsonrpc: "2.0", id: body.id, result: {} }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
opts.onCall(body, res);
|
||||
});
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
requests,
|
||||
close: () => new Promise((r) => server.close(() => r())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Drive an in-process gateway Server via the SDK's request handlers. */
|
||||
async function callGateway(
|
||||
agents: EnabledAgent[],
|
||||
token: string | null,
|
||||
method: "tools/list" | "tools/call",
|
||||
params: any,
|
||||
opts: {
|
||||
gateway?: Partial<Omit<GatewayOptions, "agents" | "token" | "server">>;
|
||||
extra?: any;
|
||||
} = {},
|
||||
) {
|
||||
const mcpServer = new Server(
|
||||
{ name: "test", version: "0.0.0" },
|
||||
{ capabilities: { tools: { listChanged: false } } },
|
||||
);
|
||||
await buildGateway({ agents, token, server: mcpServer });
|
||||
await buildGateway({ agents, token, server: mcpServer, ...opts.gateway });
|
||||
// Reach into the server's registered handlers — the SDK exposes them via
|
||||
// a private map, so we route through the public schema-keyed lookup we
|
||||
// know is wired up.
|
||||
const schema = method === "tools/list" ? ListToolsRequestSchema : CallToolRequestSchema;
|
||||
const handler = (mcpServer as any)._requestHandlers.get(schema.shape.method.value);
|
||||
if (!handler) throw new Error("handler not registered");
|
||||
return handler({ method, params }, {} as any);
|
||||
return handler({ method, params }, opts.extra ?? ({} as any));
|
||||
}
|
||||
|
||||
test("tools/list aggregates upstream tools with agent prefix", async () => {
|
||||
@@ -209,3 +249,168 @@ test("tools/call surfaces upstream JSON-RPC error as soft error", async () => {
|
||||
await upstream.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("tools/call forwards upstream SSE progress notifications", async () => {
|
||||
const upstream = await startSseAgent({
|
||||
onCall: (body, res) => {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/progress",
|
||||
params: { progressToken: "p1", message: "halfway" },
|
||||
})}\n\n`,
|
||||
);
|
||||
res.end(
|
||||
`data: ${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: body.id,
|
||||
result: {
|
||||
content: [{ type: "text", text: "done" }],
|
||||
isError: false,
|
||||
},
|
||||
})}\n\n`,
|
||||
);
|
||||
},
|
||||
});
|
||||
const notifications: any[] = [];
|
||||
try {
|
||||
const result = await callGateway(
|
||||
[{ name: "streamer", url: upstream.url, addedAt: "now" }],
|
||||
null,
|
||||
"tools/call",
|
||||
{ name: `streamer${SEP}build`, arguments: {} },
|
||||
{
|
||||
extra: {
|
||||
requestId: "req-1",
|
||||
sendNotification: async (notification: any) => {
|
||||
notifications.push(notification);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result.isError, false);
|
||||
assert.deepEqual(notifications, [
|
||||
{
|
||||
method: "notifications/progress",
|
||||
params: { progressToken: "p1", message: "halfway" },
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await upstream.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("tools/call reports upstream SSE idle timeout", async () => {
|
||||
const upstream = await startSseAgent({
|
||||
onCall: (_body, res) => {
|
||||
res.write(": connected\n\n");
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await callGateway(
|
||||
[{ name: "slow", url: upstream.url, addedAt: "now" }],
|
||||
null,
|
||||
"tools/call",
|
||||
{ name: `slow${SEP}build`, arguments: {} },
|
||||
{ gateway: { upstreamStreamIdleTimeoutMs: 25 } },
|
||||
);
|
||||
assert.equal(result.isError, true);
|
||||
assert.match(result.content[0].text, /stream idle timeout/);
|
||||
} finally {
|
||||
await upstream.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("tools/call relays upstream elicitation requests", async () => {
|
||||
let resolveResponse: (() => void) | undefined;
|
||||
const responseSeen = new Promise<void>((resolve) => { resolveResponse = resolve; });
|
||||
let upstreamResponse: any = null;
|
||||
const requests: any[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method !== "POST" || req.url !== "/mcp") {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (c) => chunks.push(c));
|
||||
req.on("end", () => {
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
requests.push({ headers: req.headers, body });
|
||||
if (body.method === "tools/call") {
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"Mcp-Session-Id": "sess-1",
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: "ask-1",
|
||||
method: "elicitation/create",
|
||||
params: { message: "Name?", requestedSchema: { type: "object" } },
|
||||
})}\n\n`,
|
||||
);
|
||||
void responseSeen.then(() => {
|
||||
res.end(
|
||||
`data: ${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: body.id,
|
||||
result: {
|
||||
content: [{ type: "text", text: upstreamResponse.result.content.answer }],
|
||||
isError: false,
|
||||
},
|
||||
})}\n\n`,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (body.id === "ask-1" && req.headers["mcp-session-id"] === "sess-1") {
|
||||
upstreamResponse = body;
|
||||
res.statusCode = 202;
|
||||
res.end();
|
||||
resolveResponse?.();
|
||||
return;
|
||||
}
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify({ jsonrpc: "2.0", id: body.id, result: {} }));
|
||||
});
|
||||
});
|
||||
const upstream = await new Promise<{ url: string; close: () => Promise<void> }>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
close: () => new Promise((r) => server.close(() => r())),
|
||||
});
|
||||
});
|
||||
});
|
||||
const clientRequests: any[] = [];
|
||||
try {
|
||||
const result = await callGateway(
|
||||
[{ name: "human", url: upstream.url, addedAt: "now" }],
|
||||
null,
|
||||
"tools/call",
|
||||
{ name: `human${SEP}run_demo`, arguments: {} },
|
||||
{
|
||||
extra: {
|
||||
requestId: "req-elicitation",
|
||||
sendNotification: async () => {},
|
||||
sendRequest: async (request: any) => {
|
||||
clientRequests.push(request);
|
||||
return { action: "accept", content: { answer: "Alice" } };
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result.isError, false);
|
||||
assert.equal(result.content[0].text, "Alice");
|
||||
assert.equal(clientRequests[0].method, "elicitation/create");
|
||||
assert.deepEqual(upstreamResponse.result, {
|
||||
action: "accept",
|
||||
content: { answer: "Alice" },
|
||||
});
|
||||
assert.ok(requests.some((r) => r.body.id === "ask-1"));
|
||||
} finally {
|
||||
await upstream.close();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user