Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .changeset/agentaos-cli-merchant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"agentaos": major
---

The `agentaos` / `agenta` CLI is now the merchant's CLI and MCP server for AgentaOS payments.

- Removed the self-custody crypto-wallet surface: `send`, `sign`, `balance`, `receive`, `deploy`, `x402`, signer and network management, and the seventeen token-moving MCP tools. Scripts that relied on them must pin `agentaos@2`.
- The CLI now covers onboarding and money: `login` (browser approval), `status`, `audit`, `verify`, `products`, `pay`, `subscriptions`.
- The MCP server (run the binary with no arguments from an agent) exposes the seven `agenta_pay_*` tools against `AGENTAOS_GATEWAY_KEY`; the same tools are exported as `agentaos/mcp` (`registerPayTools(server, getClient)`) for hosted servers.
- A refused key tells the merchant it may have been revoked in AgentaOS and how to reconnect.
2 changes: 2 additions & 0 deletions packages/mcp-remote/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
AGENTAOS_API_URL=http://localhost:8080
MCP_PUBLIC_URL=https://localhost:8788
2 changes: 2 additions & 0 deletions packages/mcp-remote/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.dev.vars
.wrangler/
72 changes: 72 additions & 0 deletions packages/mcp-remote/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# @agentaos/mcp-remote

The AgentaOS remote MCP server: a Cloudflare Worker at `https://mcp.agentaos.ai/mcp`
that lets ChatGPT, Claude.ai, Claude Desktop and any other hosted MCP client use the
same seven `agenta_pay_*` merchant tools the `agentaos` CLI serves over stdio.

Hosted assistants speak OAuth 2.1 to the MCP server. `@cloudflare/workers-oauth-provider`
implements that front door; the AgentaOS device-code approval page is the consent step.
No new platform endpoints, no new auth mechanism.

## Flow

1. The assistant hits `/mcp` without a token and discovers `/authorize`.
2. `/authorize` starts a device-code login on the platform, parks the OAuth request in KV
under a random `state`, and redirects the merchant to the existing approve page.
3. The merchant signs in if needed, picks Test or Live, and approves.
4. The approve page returns the merchant to `/callback?state=…&mode=test|live`.
5. `/callback` redeems the device code for a one-shot session, mints a secret key for the
chosen mode's networks, drops the session, and stores only the key in the grant.
6. The assistant exchanges the code at `/token`; every tool call runs with that key.

Revoking the key on the Developers tab disconnects the assistant. The Pay SDK has no
custom-fetch hook, so after a revoke the tools surface the API's own 401 message rather
than a dedicated "reconnect" sentence.

## Local development

The OAuth provider only accepts an `https` issuer, so the local Worker serves TLS on a
self-signed certificate and is reached as `https://localhost:8788` (the `dev` script
passes `--local-protocol https --host localhost:8788`; without `--host`, wrangler
rewrites local requests to the production route and the issuer comes out wrong).

```bash
cp .dev.vars.example .dev.vars # local API on :8080, public URL https://localhost:8788
pnpm --filter agentaos build # the Worker imports the tools from agentaos/mcp
pnpm --filter @agentaos/mcp-remote dev
npx @modelcontextprotocol/inspector@latest # connect to https://localhost:8788/mcp
```

The approve page honours `return` for `http(s)://localhost:*` in dev, so the whole flow
runs against the local API and app. Open `https://localhost:8788/` in the browser once
and accept the certificate, or the redirect back to `/callback` stops at the warning.

## Deploy

```bash
wrangler login
wrangler kv namespace create OAUTH_KV # paste the id into wrangler.jsonc
wrangler deploy # binds the custom domain mcp.agentaos.ai
```

## Connecting an assistant

- **ChatGPT**: Settings → Connectors → add `https://mcp.agentaos.ai/mcp`, then Approve on
the AgentaOS page that opens.
- **Claude.ai / Claude Desktop**: Customize → Connectors → Add custom connector with the
same URL, then Approve.

## Disconnecting

Revoke the key on app.agentaos.ai → Developers. Its name carries the key prefix the
connection was issued with.

## Tests

```bash
pnpm --filter @agentaos/mcp-remote test
```

The tests run on Node with a fake platform API, a fake KV and a fake OAuth helper.
`vitest.config.ts` aliases `cloudflare:workers` to a stub because the OAuth provider
imports it at module load.
29 changes: 29 additions & 0 deletions packages/mcp-remote/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@agentaos/mcp-remote",
"version": "0.1.0",
"private": true,
"description": "AgentaOS remote MCP server for ChatGPT, Claude.ai and other hosted assistants (Cloudflare Worker)",
"license": "Apache-2.0",
"type": "module",
"scripts": {
"dev": "wrangler dev --local-protocol https --host localhost:8788",
"deploy": "wrangler deploy",
"build": "tsc --noEmit",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "biome check src/"
},
"dependencies": {
"@agentaos/pay": "workspace:^",
"@cloudflare/workers-oauth-provider": "^0.10.3",
"@modelcontextprotocol/sdk": "^1.26.0",
"agentaos": "workspace:^",
"zod": "^3.24.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260908.1",
"typescript": "^5.6.0",
"vitest": "^2.0.0",
"wrangler": "^4.130.0"
}
}
215 changes: 215 additions & 0 deletions packages/mcp-remote/src/__tests__/authorize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import type { AuthRequest, CompleteAuthorizationOptions } from '@cloudflare/workers-oauth-provider';
import { describe, expect, it } from 'vitest';

import { AgentaosApi } from '../agentaos-api.js';
import { handleAuthorize, handleCallback } from '../authorize.js';
import type { Env } from '../env.js';

const API = 'http://api.test';
const PUBLIC = 'http://mcp.test';
const RAW_KEY = 'sk_test_rawsecret';

const authRequest: AuthRequest = {
responseType: 'code',
clientId: 'client-1',
redirectUri: 'https://chatgpt.com/cb',
scope: ['agentaos'],
state: 'client-state',
codeChallenge: 'abc',
codeChallengeMethod: 'S256',
};

function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}

interface Scenario {
pollStatus: 'completed' | 'denied' | 'expired';
}

function fakeFetch(scenario: Scenario, calls: { url: string; init?: RequestInit }[]): typeof fetch {
return (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
calls.push({ url, init });
if (url.endsWith('/auth/device-code')) {
return json({
deviceCode: 'dev-1',
userCode: 'A7KM-X9RD',
verificationUrl: 'http://app.test/cli-auth?code=A7KM-X9RD',
expiresIn: 600,
interval: 5,
});
}
if (url.endsWith('/auth/device-code/poll')) {
return scenario.pollStatus === 'completed'
? json({ status: 'completed', token: 'session-jwt', email: 'm@example.com' })
: json({ status: scenario.pollStatus });
}
if (url.endsWith('/networks')) {
return json([
{ name: 'base', isTestnet: false },
{ name: 'base-sepolia', isTestnet: true },
{ name: 'arbitrum', isTestnet: false },
]);
}
if (url.endsWith('/gateway/secret-keys')) {
return json({ id: 'key-1', key_prefix: 'sk_test_raws', rawKey: RAW_KEY });
}
return new Response('unexpected', { status: 500 });
}) as typeof fetch;
}

function fakeKv(): KVNamespace {
const store = new Map<string, string>();
return {
put: async (key: string, value: string) => {
store.set(key, value);
},
get: async (key: string) => store.get(key) ?? null,
delete: async (key: string) => {
store.delete(key);
},
} as unknown as KVNamespace;
}

function fakeEnv(completed: CompleteAuthorizationOptions[]): Env {
return {
OAUTH_KV: fakeKv(),
OAUTH_PROVIDER: {
parseAuthRequest: async () => authRequest,
lookupClient: async () => ({ clientId: 'client-1', redirectUris: [], clientName: 'ChatGPT' }),
completeAuthorization: async (options: CompleteAuthorizationOptions) => {
completed.push(options);
return { redirectTo: 'https://chatgpt.com/cb?code=1' };
},
} as unknown as Env['OAUTH_PROVIDER'],
AGENTAOS_API_URL: API,
MCP_PUBLIC_URL: PUBLIC,
};
}

describe('remote MCP authorization', () => {
let env: Env;
let completed: CompleteAuthorizationOptions[];
let calls: { url: string; init?: RequestInit }[];

function setup(scenario: Scenario = { pollStatus: 'completed' }) {
calls = [];
completed = [];
env = fakeEnv(completed);
return new AgentaosApi(API, fakeFetch(scenario, calls));
}

async function authorize(api: AgentaosApi): Promise<string> {
const response = await handleAuthorize({
request: new Request(`${PUBLIC}/authorize?client_id=client-1`),
env,
api,
});
expect(response.status).toBe(302);
const location = new URL(response.headers.get('location') ?? '');
return new URL(location.searchParams.get('return') ?? '').searchParams.get('state') ?? '';
}

function callback(api: AgentaosApi, state: string, mode: string) {
return handleCallback({
request: new Request(`${PUBLIC}/callback?state=${state}&mode=${mode}`),
env,
api,
});
}

it('redirects to the approve page with return and client, keeping the user code', async () => {
const api = setup();
const response = await handleAuthorize({
request: new Request(`${PUBLIC}/authorize?client_id=client-1`),
env,
api,
});
const location = new URL(response.headers.get('location') ?? '');
expect(location.origin + location.pathname).toBe('http://app.test/cli-auth');
expect(location.searchParams.get('code')).toBe('A7KM-X9RD');
expect(location.searchParams.get('client')).toBe('ChatGPT');
expect(location.searchParams.get('return')).toMatch(/^http:\/\/mcp\.test\/callback\?state=/);
});

it('stores the handoff so the callback can find the device code', async () => {
const api = setup();
const state = await authorize(api);
const stored = await env.OAUTH_KV.get(`handoff:${state}`);
expect(JSON.parse(stored ?? '{}')).toMatchObject({
deviceCode: 'dev-1',
clientName: 'ChatGPT',
authRequest,
});
});

it('mints a test key from testnet networks only and completes authorization', async () => {
const api = setup();
const state = await authorize(api);
const response = await callback(api, state, 'test');
expect(response.status).toBe(302);
expect(response.headers.get('location')).toBe('https://chatgpt.com/cb?code=1');

const keyCall = calls.find((c) => c.url.endsWith('/gateway/secret-keys'));
expect(JSON.parse(String(keyCall?.init?.body))).toEqual({
supportedNetworks: ['base-sepolia'],
label: 'ChatGPT',
});
expect(new Headers(keyCall?.init?.headers).get('authorization')).toBe('Bearer session-jwt');

expect(completed).toHaveLength(1);
expect(completed[0]).toMatchObject({
request: authRequest,
userId: 'm@example.com',
scope: ['agentaos'],
metadata: { clientName: 'ChatGPT', keyId: 'key-1', keyPrefix: 'sk_test_raws' },
props: { apiKey: RAW_KEY, keyPrefix: 'sk_test_raws', mode: 'test' },
});
});

it('picks mainnet networks for a live connection', async () => {
const api = setup();
const state = await authorize(api);
await callback(api, state, 'live');
const keyCall = calls.find((c) => c.url.endsWith('/gateway/secret-keys'));
expect(JSON.parse(String(keyCall?.init?.body))).toEqual({
supportedNetworks: ['base', 'arbitrum'],
label: 'ChatGPT',
});
expect(completed[0]?.props).toMatchObject({ mode: 'live' });
});

it('rejects an unknown state with 404', async () => {
const api = setup();
const response = await callback(api, 'nope', 'test');
expect(response.status).toBe(404);
});

it('rejects an unknown mode with 400', async () => {
const api = setup();
const state = await authorize(api);
const response = await callback(api, state, 'staging');
expect(response.status).toBe(400);
});

it('answers a denied approval with 400 and never mints a key', async () => {
const api = setup({ pollStatus: 'denied' });
const state = await authorize(api);
const response = await callback(api, state, 'test');
expect(response.status).toBe(400);
expect(calls.some((c) => c.url.endsWith('/gateway/secret-keys'))).toBe(false);
expect(completed).toHaveLength(0);
});

it('uses the handoff once: a second callback with the same state is 404', async () => {
const api = setup();
const state = await authorize(api);
await callback(api, state, 'test');
const second = await callback(api, state, 'test');
expect(second.status).toBe(404);
});
});
2 changes: 2 additions & 0 deletions packages/mcp-remote/src/__tests__/cloudflare-workers.stub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Node-side stand-in for the `cloudflare:workers` module (see vitest.config.ts). */
export class WorkerEntrypoint {}
56 changes: 56 additions & 0 deletions packages/mcp-remote/src/__tests__/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';

import type { Env } from '../env.js';
import { handleMcp } from '../mcp.js';

const EXPECTED_TOOLS = [
'agenta_pay_create_checkout',
'agenta_pay_get_checkout',
'agenta_pay_list_checkouts',
'agenta_pay_list_subscriptions',
'agenta_pay_cancel_subscription',
'agenta_pay_list_customers',
'agenta_pay_send_receipt',
];

const env = {
AGENTAOS_API_URL: 'http://api.test',
MCP_PUBLIC_URL: 'http://mcp.test',
} as Env;

/** The transport answers JSON or SSE depending on negotiation; read either. */
async function readJsonRpcResult(response: Response): Promise<{ tools: { name: string }[] }> {
const body = await response.text();
const contentType = response.headers.get('content-type') ?? '';
const payload = contentType.includes('text/event-stream')
? (body
.split('\n')
.find((line) => line.startsWith('data:'))
?.slice('data:'.length)
.trim() ?? '')
: body;
return JSON.parse(payload).result;
}

describe('remote MCP endpoint', () => {
it('lists the seven merchant tools for a connection', async () => {
const request = new Request('http://mcp.test/mcp', {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
});

const response = await handleMcp(request, env, {
apiKey: 'sk_test_dummy',
keyPrefix: 'sk_test_dumm',
mode: 'test',
});

expect(response.status).toBe(200);
const { tools } = await readJsonRpcResult(response);
expect(tools.map((t) => t.name).sort()).toEqual([...EXPECTED_TOOLS].sort());
});
});
Loading
Loading