Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/clear-run-result-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@call-e/cli": patch
"@call-e/codex-plugin": patch
"@call-e/claude-plugin": patch
---

Clarify the nested get_call_run result fields in CLI and packaged agent guidance.
29 changes: 25 additions & 4 deletions docs/mcp/openagent-oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ After extracting the structured object, use these handoff fields:
| --- | --- |
| `plan_call` | `ready_to_run`, `plan_id`, `confirm_token`, and any `clarifying_questions` |
| `run_call` | `run_id`, current `status`, and `next_step` when present |
| `get_call_run` | `run_id`, `status`, `activity`, result fields, and `next_step` when present |
| `get_call_run` | `run_id`, `status`, `activity`, `result.summary`, `result.transcript`, `result.outcome`, `result.extracted`, and `next_step` when present |

These are workflow handoff fields, not an exhaustive output schema. Follow the
current tool definitions returned by `tools/list` and do not infer fields that
Expand Down Expand Up @@ -218,9 +218,30 @@ Important inputs:
- `cursor`: optional pagination cursor from a previous `get_call_run` response.
- `limit`: optional maximum number of activity entries to return.

The response can include status, activity, summary, details, transcript, and
`next_step` guidance. Follow the workflow below until the run reaches a
terminal state.
Inside the structured object, `status`, `activity`, and `next_step` are
run-level fields. Call content is nested under `result`: read
`result.post_summary` or `result.summary`, `result.transcript`,
`result.outcome`, `result.extracted`, and `result.call_id` when present.
Do not read `summary` or `transcript` directly from `structuredContent`.

For example, this synthetic excerpt shows the nesting, not a complete response:

```json
{
"run_id": "run_example",
"status": "COMPLETED",
"activity": [],
"result": {
"summary": "The recipient confirmed availability.",
"transcript": "[00:00:01] USER: Yes, I am available.",
"outcome": { "task_completed": true },
"extracted": {}
}
}
```

An absent or empty transcript remains unavailable; do not fill it from the
summary. Follow the workflow below until the run reaches a terminal state.

### Reliable terminal-state workflow

Expand Down
15 changes: 14 additions & 1 deletion examples/mcp-oauth-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ export MCP_LOG_FILE=/tmp/calle-mcp-example.log
The log file receives the same JSON events printed to stdout, plus timestamps.
Do not publish it because live runs may include a browser authorization URL.

## Authorization

Connection and tool discovery can succeed before login. A protected request such
as `plan_call` may be the first request that requires OAuth. Complete the browser
authorization when prompted; the examples obtain their own token through dynamic
registration and PKCE. Do not read or copy the CLI's private token cache.

The TypeScript example handles an explicit authorization challenge at connection
or request time and retries the challenged request after authorization. A timeout
or other uncertain tool error is not an authorization challenge and is not retried.
The Python SDK handles request-time authorization through its HTTP auth provider.

## Plan Call Example

`plan_call` creates a CALL-E call plan. It does not start the call; running the
Expand Down Expand Up @@ -64,7 +76,8 @@ pnpm test:e2e

## Python

The Python example uses MCP Python SDK 2.x and `httpx2`.
The Python example pins MCP Python SDK `2.1.1`, verified with browser OAuth
and `plan_call`, and uses `httpx2`.

```bash
cd examples/mcp-oauth-client/python
Expand Down
2 changes: 1 addition & 1 deletion examples/mcp-oauth-client/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description = "Minimal standard MCP OAuth client example for CALL-E."
requires-python = ">=3.10"
dependencies = [
"httpx2>=2.5.0,<3",
"mcp>=2.1.1,<3",
"mcp==2.1.1",
]

[dependency-groups]
Expand Down
25 changes: 24 additions & 1 deletion examples/mcp-oauth-client/python/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
FAKE_SERVER = ROOT / "shared" / "fake-mcp-broker-server.mjs"


def start_fake_server(*, no_resources=False, unauthorized_mcp=False, oauth_issuer=None, oauth_redirects=False):
def start_fake_server(*, no_resources=False, unauthorized_mcp=False, oauth_issuer=None, oauth_redirects=False, public_discovery=False):
env = os.environ.copy()
if public_discovery:
env["FAKE_PUBLIC_DISCOVERY"] = "1"
if no_resources:
env["FAKE_NO_RESOURCES"] = "1"
if unauthorized_mcp:
Expand Down Expand Up @@ -253,3 +255,24 @@ def test_oauth_client_follows_registration_and_token_redirects():
assert_no_secrets(result.stdout + result.stderr)
finally:
stop_fake_server(process)


@pytest.mark.parametrize("tool_name", ["plan_call", ""])
def test_oauth_client_authorizes_after_public_discovery(tool_name):
process, fake = start_fake_server(public_discovery=True)
try:
result = run_client({
"MCP_SERVER_URL": fake["server_url"],
"MCP_OAUTH_AUTO_AUTHORIZE": "1",
"MCP_TOOL_NAME": tool_name,
"MCP_TOOL_ARGS_JSON": '{"user_input":"Plan only; ask for missing details. Do not start a call."}',
})
assert result.returncode == 0, result.stderr
assert_no_secrets(result.stdout + result.stderr)
state = read_state(fake["state_url"])
assert len(state["oauth_tokens"]) == 1
assert not next(r for r in state["mcp_requests"] if r["method"] == "tools/list")["has_bearer_token"]
assert [c["name"] for c in state["tool_calls"]] == ([tool_name] if tool_name else [])
assert len(state["resource_reads"]) == 1
finally:
stop_fake_server(process)
73 changes: 45 additions & 28 deletions examples/mcp-oauth-client/typescript/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ async function getAuthorizationCode(config: Config, authorizationUrl: URL): Prom
return waitForLocalCallback(config.redirectUri, authorizationUrl);
}

async function connect(config: Config): Promise<{ client: Client; transport: StreamableHTTPClientTransport }> {
async function connect(config: Config) {
let authorizationUrl: URL | null = null;
const clientMetadata: OAuthClientMetadata = {
client_name: "CALL-E OAuth MCP example",
Expand All @@ -170,21 +170,35 @@ async function connect(config: Config): Promise<{ client: Client; transport: Str
authorizationUrl = url;
});

async function authorize(transport: StreamableHTTPClientTransport) {
if (!authorizationUrl) {
throw new Error("OAuth authorization was required, but no authorization URL was produced.");
}
const code = await getAuthorizationCode(config, authorizationUrl);
await transport.finishAuth(code);
}

for (let attempt = 0; attempt < 3; attempt += 1) {
const client = new Client({ name: "calle-oauth-example", version: "0.0.0" }, { capabilities: {} });
const transport = new StreamableHTTPClientTransport(new URL(config.serverUrl), { authProvider: provider });
try {
await client.connect(transport);
return { client, transport };
async function withAuthorization<T>(request: () => Promise<T>): Promise<T> {
try {
return await request();
} catch (error) {
// Retry only an explicit auth challenge, never an uncertain tool outcome.
if (!(error instanceof UnauthorizedError)) throw error;
await authorize(transport);
return request();
}
}
return { client, transport, withAuthorization };
} catch (error) {
if (!(error instanceof UnauthorizedError)) {
throw error;
}
if (!authorizationUrl) {
throw new Error("OAuth authorization was required, but no authorization URL was produced.");
}
const code = await getAuthorizationCode(config, authorizationUrl);
await transport.finishAuth(code);
await authorize(transport);
await transport.close().catch(() => {});
}
}
Expand All @@ -193,32 +207,35 @@ async function connect(config: Config): Promise<{ client: Client; transport: Str
}

async function runClient(config: Config): Promise<void> {
const { client, transport } = await connect(config);
const { client, transport, withAuthorization } = await connect(config);
emit("connected", { server_url: config.serverUrl, session_id: transport.sessionId || null });

const tools = await client.listTools();
emit("tools/list", { count: tools.tools.length, tools: tools.tools.map((tool) => tool.name) });
try {
const tools = await withAuthorization(() => client.listTools());
emit("tools/list", { count: tools.tools.length, tools: tools.tools.map((tool) => tool.name) });

if (config.toolName) {
const result = await client.callTool({ name: config.toolName, arguments: config.toolArgs });
emit("tools/call", { tool_name: config.toolName, result });
}
if (config.toolName) {
const result = await withAuthorization(() => client.callTool({ name: config.toolName!, arguments: config.toolArgs }));
emit("tools/call", { tool_name: config.toolName, result });
}

const resources = await client.listResources().catch((error) => {
emit("resources/list", { skipped: true, message: error?.message || String(error) });
return { resources: [] };
});
emit("resources/list", { count: resources.resources.length });

const firstResource = resources.resources[0];
if (firstResource) {
const result = await client.readResource({ uri: firstResource.uri });
emit("resources/read", { uri: firstResource.uri, result: summarizeResourceResult(result as Record<string, unknown>) });
} else {
emit("resources/read", { skipped: true, message: "no resources available" });
const resources = await withAuthorization(() => client.listResources()).catch((error) => {
if (error?.code !== -32601) throw error;
emit("resources/list", { skipped: true, message: error?.message || String(error) });
return { resources: [] };
});
emit("resources/list", { count: resources.resources.length });

const firstResource = resources.resources[0];
if (firstResource) {
const result = await withAuthorization(() => client.readResource({ uri: firstResource.uri }));
emit("resources/read", { uri: firstResource.uri, result: summarizeResourceResult(result as Record<string, unknown>) });
} else {
emit("resources/read", { skipped: true, message: "no resources available" });
}
} finally {
await transport.close().catch(() => {});
}

await transport.close().catch(() => {});
}

export async function main(): Promise<void> {
Expand Down
41 changes: 41 additions & 0 deletions examples/mcp-oauth-client/typescript/test/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,44 @@ test("OAuth example reports repeated MCP 401 without leaking tokens", async (t)
assert.match(result.stderr, /oauth_client_error/);
assertNoSecrets(`${result.stdout}\n${result.stderr}`);
});

for (const toolName of ["plan_call", ""]) {
test(`OAuth example authorizes after public discovery (${toolName || "resources"})`, async (t) => {
const fake = await startFakeServer({ publicDiscovery: true });
t.after(() => fake.close());
const result = await runClient({
MCP_SERVER_URL: fake.serverUrl,
MCP_OAUTH_AUTO_AUTHORIZE: "1",
MCP_TOOL_NAME: toolName,
MCP_TOOL_ARGS_JSON: '{"user_input":"Plan only; ask for missing details. Do not start a call."}',
});
assert.equal(result.code, 0, result.stderr);
assertNoSecrets(result.stdout + result.stderr);
const state = await readState(fake.stateUrl);
assert.equal(state.oauth_tokens.length, 1);
assert.equal(state.mcp_requests.find((r: {method: string}) => r.method === "tools/list").has_bearer_token, false);
assert.deepEqual(state.tool_calls.map((c: {name: string}) => c.name), toolName ? [toolName] : []);
assert.equal(state.resource_reads.length, 1);
});
}

for (const toolName of ["plan_call", ""]) {
test(`OAuth example stops after repeated request-time 401 (${toolName || "resources"})`, async (t) => {
const fake = await startFakeServer({ publicDiscovery: true, unauthorizedMcp: true });
t.after(() => fake.close());
const result = await runClient({
MCP_SERVER_URL: fake.serverUrl,
MCP_OAUTH_AUTO_AUTHORIZE: "1",
MCP_TOOL_NAME: toolName,
MCP_TOOL_ARGS_JSON: '{"user_input":"Plan only. Do not start a call."}',
});
assert.notEqual(result.code, 0);
assertNoSecrets(result.stdout + result.stderr);
const state = await readState(fake.stateUrl);
const method = toolName ? "tools/call" : "resources/list";
// The SDK may refresh once before its repeated-401 circuit breaker stops.
const attempts = state.mcp_requests.filter((r: {method: string}) => r.method === method).length;
assert.ok(attempts >= 2 && attempts <= 3);
assert.deepEqual(state.tool_calls, []);
});
}
5 changes: 4 additions & 1 deletion examples/shared/fake-mcp-broker-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ function unauthorized(res, baseUrl, scope = "openid email profile") {
function normalizeOptions(options = {}) {
return {
noResources: Boolean(options.noResources),
publicDiscovery: Boolean(options.publicDiscovery),
unauthorizedMcp: Boolean(options.unauthorizedMcp),
brokerPendingFirst: Boolean(options.brokerPendingFirst),
oauthIssuer: options.oauthIssuer || null,
Expand Down Expand Up @@ -150,7 +151,8 @@ export async function startFakeServer(options = {}) {
const payload = await readJson(req);
state.mcp_requests.push(redactMcpRequest(req, payload, ACCESS_TOKEN));

if (opts.unauthorizedMcp || req.headers.authorization !== `Bearer ${ACCESS_TOKEN}`) {
const publicDiscovery = opts.publicDiscovery && ["initialize", "notifications/initialized", "tools/list"].includes(payload.method);
if (!publicDiscovery && (opts.unauthorizedMcp || req.headers.authorization !== `Bearer ${ACCESS_TOKEN}`)) {
unauthorized(res, baseUrl);
return;
}
Expand Down Expand Up @@ -645,6 +647,7 @@ export async function startFakeServer(options = {}) {
async function runCli() {
const fake = await startFakeServer({
noResources: process.env.FAKE_NO_RESOURCES === "1",
publicDiscovery: process.env.FAKE_PUBLIC_DISCOVERY === "1",
unauthorizedMcp: process.env.FAKE_UNAUTHORIZED_MCP === "1",
brokerPendingFirst: process.env.FAKE_BROKER_PENDING_FIRST === "1",
oauthIssuer: process.env.FAKE_OAUTH_ISSUER,
Expand Down
19 changes: 13 additions & 6 deletions packages/claude-plugin/plugin/skills/calle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,13 @@ If `ts` is missing, use the message by itself. If there is no activity, use
`- Waiting for the next status update.` Do not include the final summary,
details, or transcript until a terminal status is returned.

<!-- sync-with: docs/mcp/openagent-oauth.md#get_call_run -->
For the template below, use the structured run object at
`result.structuredContent` after `call status`, or
`status_result.structuredContent` after start/run/recover. Within that object,
call content is nested under `result`; status and activity are at the top level.
See [Run result fields](references/commands.md#run-result-fields).

When the call reaches a terminal status, reply with the final call result,
including these sections in this order:

Expand All @@ -205,16 +212,16 @@ including these sections in this order:
<status>

[Call Summary]
<post_summary or summary or message>
<result.post_summary or result.summary or message>

[Details]
Callee Number: <primary callee or Not available>
Duration: <duration or Not available>
Time: <start/end time or Not available>
Call id: <call_id or Not available>
Callee Number: <result.extracted.to_phones[0] or Not available>
Duration: <result.extracted.calling.duration_seconds or Not available>
Time: <result.extracted.calling.started_at or result.extracted.calling.ended_at or Not available>
Call id: <result.call_id or Not available>

[Transcript]
<transcript or Not available.>
<result.transcript or Not available.>
```

If the user asked for extra final content, such as key takeaways or next steps,
Expand Down
16 changes: 16 additions & 0 deletions packages/claude-plugin/plugin/skills/calle/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,22 @@ Call id: <result.call_id or Not available>
If the user requested extra final content, add it after `[Transcript]` using a
short heading and only information present in the JSON output.

## Run result fields

<!-- sync-with: docs/mcp/openagent-oauth.md#get_call_run -->
In this guide, `result` in call-content paths means the call-content object
inside `structuredContent`, not the outer CLI `result` wrapper. For
`call status`, read summary and transcript from
`result.structuredContent.result.summary` and
`result.structuredContent.result.transcript`. For start/run/recover, use
`status_result.structuredContent.result`; direct MCP uses
`structuredContent.result`.

Read `post_summary` or `summary`, `transcript`, `outcome`, `extracted`, and
`call_id` from that nested object when present. Read `status`, `activity`,
`message`, and `next_step` from its parent structured object. Missing or empty
fields stay unavailable; a summary does not substitute for a transcript.

## JSON handling

- Treat command output as JSON.
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@ than the latest call state. See the
[MCP tool result envelope](../../../docs/mcp/openagent-oauth.md#tool-result-envelope)
for the direct protocol shape and SDK field-name differences.

For `get_call_run`, the structured object has its own nested `result` containing
call content. In `call status` output, the summary is therefore
`result.structuredContent.result.summary` and the transcript is
`result.structuredContent.result.transcript`. In start/run/recover output,
use `status_result.structuredContent.result` instead. `status`, `activity`, and
`next_step` remain directly on the structured object. See the
[run result fields](../../../docs/mcp/openagent-oauth.md#get_call_run) for the
other nested fields.

## Finding Command Help

Help is available at the root, command-group, and subcommand levels:
Expand Down
Loading
Loading