Skip to content

Commit ae31fd5

Browse files
authored
Merge pull request #7 from agents-oss/ci/deploy_mcp_server
feat: update mcp to list cluster agents
2 parents 57d2092 + 65467f8 commit ae31fd5

8 files changed

Lines changed: 320 additions & 47 deletions

File tree

docs/concepts/operating-modes.md

Lines changed: 67 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,13 @@ Install the MCP server in your AI editor:
8282
}
8383
```
8484

85-
**Claude Code** (`.claude/settings.json` or via `claude mcp add`):
85+
**Claude Code** (via `claude mcp add`):
8686
```bash
8787
claude mcp add agentspec -- npx -y @agentspec/mcp
8888
```
8989

90+
In sidecar mode no env vars are needed — tool arguments point directly at the sidecar.
91+
9092
Tool arguments for sidecar mode:
9193
```json
9294
// agentspec_health
@@ -158,29 +160,76 @@ port-forward.
158160

159161
### MCP configuration
160162

161-
Same install as sidecar mode — `@agentspec/mcp` supports both modes via tool arguments.
163+
For operator mode, port-forward the control plane service to your local machine:
164+
165+
```bash
166+
kubectl port-forward svc/agentspec-operator -n agentspec 8080:80
167+
```
168+
169+
Then configure the MCP server with env vars so all tools automatically use the cluster:
162170

163-
Tool arguments for operator mode:
164171
```json
165-
// agentspec_health
166172
{
167-
"agentName": "budget-assistant",
168-
"controlPlaneUrl": "http://localhost:8080",
169-
"adminKey": "sk-optional"
173+
"mcpServers": {
174+
"agentspec": {
175+
"command": "npx",
176+
"args": ["-y", "@agentspec/mcp"],
177+
"env": {
178+
"AGENTSPEC_CONTROL_PLANE_URL": "http://localhost:8080",
179+
"AGENTSPEC_ADMIN_KEY": "your-admin-key"
180+
}
181+
}
182+
}
170183
}
184+
```
171185

172-
// agentspec_audit
173-
{
174-
"file": "agent.yaml",
175-
"agentName": "budget-assistant",
176-
"controlPlaneUrl": "http://localhost:8080"
177-
}
186+
| Env var | Description |
187+
|---|---|
188+
| `AGENTSPEC_CONTROL_PLANE_URL` | Control plane URL (e.g. `http://localhost:8080` after port-forward) |
189+
| `AGENTSPEC_ADMIN_KEY` | The admin key for the control plane API. This is the same value as `controlPlane.apiKey` in the Helm chart (`values.yaml`). Empty by default — if you didn't set it during install, omit it or leave it as `""`. |
178190

179-
// agentspec_gap
180-
{
181-
"agentName": "budget-assistant",
182-
"controlPlaneUrl": "http://localhost:8080"
183-
}
191+
This is the same key used in three places:
192+
193+
| Where | Setting | Purpose |
194+
|---|---|---|
195+
| Control plane deployment | `AGENTSPEC_ADMIN_KEY` env var | The control plane checks this on every admin request |
196+
| Helm chart | `controlPlane.apiKey` in `values.yaml` | The operator uses this to poll `GET /api/v1/agents` |
197+
| MCP server config | `AGENTSPEC_ADMIN_KEY` env var | Your editor uses this to query the control plane |
198+
199+
If you installed with the default Helm values (`controlPlane.apiKey: ""`), the control plane has no admin key set and returns **503** on admin endpoints. To enable them, set the key:
200+
201+
```bash
202+
# Generate a key
203+
openssl rand -hex 32
204+
205+
# Set it on the control plane (pick one):
206+
# Helm upgrade:
207+
helm upgrade agentspec-operator oci://ghcr.io/agents-oss/charts/agentspec-operator \
208+
--set controlPlane.apiKey=<your-key> \
209+
--namespace agentspec-system
210+
211+
# Or create the secret directly:
212+
kubectl create secret generic agentspec-control-plane \
213+
--namespace=agentspec-system \
214+
--from-literal=apiKey=<your-key>
215+
```
216+
217+
Then use the same key in your MCP config.
218+
219+
With these env vars set, all cluster-aware tools (`agentspec_list_agents`, `agentspec_health`, `agentspec_audit`, `agentspec_gap`, `agentspec_proof`) automatically query the cluster. Per-call arguments still override env vars when needed.
220+
221+
Tools that only work locally (`agentspec_validate`, `agentspec_scan`, `agentspec_generate`, `agentspec_diff`) are unaffected.
222+
223+
You can still pass tool arguments explicitly to override the defaults:
224+
```json
225+
// agentspec_health — override to target a specific agent
226+
{ "agentName": "budget-assistant" }
227+
228+
// agentspec_list_agents — no args needed, fetches all cluster agents
229+
{}
230+
231+
// agentspec_audit — local manifest + cluster proofs
232+
{ "file": "agent.yaml", "agentName": "budget-assistant" }
184233
```
185234

186235
---

docs/quick-start.md

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -171,15 +171,36 @@ kubectl apply -f ./generated/k8s/service.yaml
171171

172172
Install `@agentspec/mcp` to use AgentSpec tools directly inside Claude Code, Cursor, or Windsurf:
173173

174+
**Local development** (validate, health, audit, scan, generate from local files):
174175
```bash
175176
# Claude Code
176177
claude mcp add agentspec -- npx -y @agentspec/mcp
177-
178-
# Cursor / Windsurf / any MCP-compatible editor — add to mcpServers config:
179-
# { "mcpServers": { "agentspec": { "command": "npx", "args": ["-y", "@agentspec/mcp"] } } }
180178
```
181179

182-
See [Operating Modes](./concepts/operating-modes) for sidecar vs operator configuration.
180+
**Cluster mode** (list agents, health, gap, proof from the control plane):
181+
182+
Port-forward the control plane first:
183+
```bash
184+
kubectl port-forward svc/agentspec-operator -n agentspec 8080:80
185+
```
186+
187+
Then add `env` to your MCP config (`.claude/settings.json` or Cursor/Windsurf equivalent):
188+
```json
189+
{
190+
"mcpServers": {
191+
"agentspec": {
192+
"command": "npx",
193+
"args": ["-y", "@agentspec/mcp"],
194+
"env": {
195+
"AGENTSPEC_CONTROL_PLANE_URL": "http://localhost:8080",
196+
"AGENTSPEC_ADMIN_KEY": ""
197+
}
198+
}
199+
}
200+
}
201+
```
202+
203+
`AGENTSPEC_ADMIN_KEY` is the same value as `controlPlane.apiKey` in the Helm chart — empty by default. See [Operating Modes](./concepts/operating-modes) for how to set it up and the full guide on sidecar vs operator configuration.
183204

184205
## What to do next
185206

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2+
import { resolveCluster } from '../cluster-config.js'
3+
4+
describe('resolveCluster', () => {
5+
const origEnv = { ...process.env }
6+
7+
afterEach(() => {
8+
process.env = { ...origEnv }
9+
})
10+
11+
it('returns explicit args when provided', () => {
12+
const result = resolveCluster({ controlPlaneUrl: 'http://explicit:8080', adminKey: 'key-1' })
13+
expect(result).toEqual({ controlPlaneUrl: 'http://explicit:8080', adminKey: 'key-1' })
14+
})
15+
16+
it('falls back to env vars when args are undefined', () => {
17+
process.env['AGENTSPEC_CONTROL_PLANE_URL'] = 'http://env:8080'
18+
process.env['AGENTSPEC_ADMIN_KEY'] = 'env-key'
19+
20+
const result = resolveCluster({})
21+
expect(result).toEqual({ controlPlaneUrl: 'http://env:8080', adminKey: 'env-key' })
22+
})
23+
24+
it('explicit args override env vars', () => {
25+
process.env['AGENTSPEC_CONTROL_PLANE_URL'] = 'http://env:8080'
26+
process.env['AGENTSPEC_ADMIN_KEY'] = 'env-key'
27+
28+
const result = resolveCluster({ controlPlaneUrl: 'http://override:9090', adminKey: 'override-key' })
29+
expect(result).toEqual({ controlPlaneUrl: 'http://override:9090', adminKey: 'override-key' })
30+
})
31+
32+
it('returns undefined when neither args nor env are set', () => {
33+
delete process.env['AGENTSPEC_CONTROL_PLANE_URL']
34+
delete process.env['AGENTSPEC_ADMIN_KEY']
35+
36+
const result = resolveCluster({})
37+
expect(result).toEqual({ controlPlaneUrl: undefined, adminKey: undefined })
38+
})
39+
40+
it('partial override — controlPlaneUrl from arg, adminKey from env', () => {
41+
process.env['AGENTSPEC_ADMIN_KEY'] = 'env-key'
42+
43+
const result = resolveCluster({ controlPlaneUrl: 'http://explicit:8080' })
44+
expect(result).toEqual({ controlPlaneUrl: 'http://explicit:8080', adminKey: 'env-key' })
45+
})
46+
})
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { mkdtempSync } from 'fs'
3+
import { join } from 'path'
4+
import { tmpdir } from 'os'
5+
import { listAgents } from '../tools/listAgents.js'
6+
7+
const fetchMock = vi.fn()
8+
vi.stubGlobal('fetch', fetchMock)
9+
10+
const CLUSTER_AGENTS = JSON.stringify([
11+
{ agentName: 'budget-assistant', runtime: 'python', phase: 'running', grade: 'B', score: 82, lastSeen: '2026-03-08T12:00:00Z' },
12+
{ agentName: 'gym-coach', runtime: 'node', phase: 'running', grade: 'A', score: 95, lastSeen: '2026-03-08T12:01:00Z' },
13+
])
14+
15+
describe('listAgents — cluster mode', () => {
16+
beforeEach(() => {
17+
fetchMock.mockReset()
18+
})
19+
20+
it('fetches agents from control plane when controlPlaneUrl is provided', async () => {
21+
fetchMock.mockResolvedValue({ ok: true, text: async () => CLUSTER_AGENTS })
22+
23+
const result = JSON.parse(await listAgents({ controlPlaneUrl: 'http://localhost:8080' }))
24+
25+
expect(fetchMock).toHaveBeenCalledOnce()
26+
expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:8080/api/v1/agents')
27+
expect(result.agents).toHaveLength(2)
28+
expect(result.agents[0].agentName).toBe('budget-assistant')
29+
expect(result.source).toBe('cluster')
30+
})
31+
32+
it('sends X-Admin-Key header when adminKey is provided', async () => {
33+
fetchMock.mockResolvedValue({ ok: true, text: async () => CLUSTER_AGENTS })
34+
35+
await listAgents({ controlPlaneUrl: 'http://localhost:8080', adminKey: 'sk-secret' })
36+
37+
const headers = fetchMock.mock.calls[0][1].headers
38+
expect(headers['X-Admin-Key']).toBe('sk-secret')
39+
})
40+
41+
it('omits X-Admin-Key header when adminKey is not provided', async () => {
42+
fetchMock.mockResolvedValue({ ok: true, text: async () => CLUSTER_AGENTS })
43+
44+
await listAgents({ controlPlaneUrl: 'http://localhost:8080' })
45+
46+
const headers = fetchMock.mock.calls[0][1].headers
47+
expect(headers['X-Admin-Key']).toBeUndefined()
48+
})
49+
50+
it('throws on non-ok response', async () => {
51+
fetchMock.mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' })
52+
53+
await expect(
54+
listAgents({ controlPlaneUrl: 'http://localhost:8080' }),
55+
).rejects.toThrow('401')
56+
})
57+
58+
it('strips trailing slash from controlPlaneUrl', async () => {
59+
fetchMock.mockResolvedValue({ ok: true, text: async () => '[]' })
60+
61+
await listAgents({ controlPlaneUrl: 'http://localhost:8080/' })
62+
63+
expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:8080/api/v1/agents')
64+
})
65+
66+
it('returns empty agents array when cluster has none', async () => {
67+
fetchMock.mockResolvedValue({ ok: true, text: async () => '[]' })
68+
69+
const result = JSON.parse(await listAgents({ controlPlaneUrl: 'http://localhost:8080' }))
70+
71+
expect(result.agents).toEqual([])
72+
expect(result.source).toBe('cluster')
73+
})
74+
})
75+
76+
describe('listAgents — local mode (no controlPlaneUrl)', () => {
77+
it('falls back to filesystem scan when no controlPlaneUrl', async () => {
78+
const emptyDir = mkdtempSync(join(tmpdir(), 'agentspec-test-'))
79+
const result = JSON.parse(await listAgents({ dir: emptyDir }))
80+
81+
expect(result.source).toBe('local')
82+
})
83+
})
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Resolve cluster connection config: per-call args override env vars.
3+
*
4+
* Priority: explicit arg > AGENTSPEC_CONTROL_PLANE_URL / AGENTSPEC_ADMIN_KEY env > undefined
5+
*/
6+
7+
export interface ClusterConfig {
8+
controlPlaneUrl?: string
9+
adminKey?: string
10+
}
11+
12+
export function resolveCluster(args: ClusterConfig): ClusterConfig {
13+
return {
14+
controlPlaneUrl: args.controlPlaneUrl || process.env['AGENTSPEC_CONTROL_PLANE_URL'] || undefined,
15+
adminKey: args.adminKey || process.env['AGENTSPEC_ADMIN_KEY'] || undefined,
16+
}
17+
}

packages/mcp-server/src/index.ts

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { gap } from './tools/gap.js'
2020
import { diff } from './tools/diff.js'
2121
import { listAgents } from './tools/listAgents.js'
2222
import { proof } from './tools/proof.js'
23+
import { resolveCluster } from './cluster-config.js'
2324

2425
// ── Types ─────────────────────────────────────────────────────────────────────
2526

@@ -154,11 +155,13 @@ const TOOLS: ToolDef[] = [
154155
},
155156
{
156157
name: 'agentspec_list_agents',
157-
description: 'Find all agent.yaml files under a directory and return a summary list (name, version, model)',
158+
description: 'List agents. In cluster mode (controlPlaneUrl provided or AGENTSPEC_CONTROL_PLANE_URL env set), fetches live agents from the control plane. Otherwise, scans the local filesystem for agent.yaml files.',
158159
inputSchema: {
159160
type: 'object',
160161
properties: {
161-
dir: { type: 'string', description: 'Directory to search (default: current working directory)' },
162+
dir: { type: 'string', description: 'Directory to search for agent.yaml files (local mode, default: cwd)' },
163+
controlPlaneUrl: { type: 'string', description: 'Control plane URL to list cluster agents (overrides AGENTSPEC_CONTROL_PLANE_URL env)' },
164+
adminKey: { type: 'string', description: 'X-Admin-Key for the control plane API (overrides AGENTSPEC_ADMIN_KEY env)' },
162165
},
163166
required: [],
164167
},
@@ -171,23 +174,27 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
171174
switch (name) {
172175
case 'agentspec_validate':
173176
return validate(args['file'] as string)
174-
case 'agentspec_health':
177+
case 'agentspec_health': {
178+
const cluster = resolveCluster({ controlPlaneUrl: args['controlPlaneUrl'] as string | undefined, adminKey: args['adminKey'] as string | undefined })
175179
return health({
176180
file: args['file'] as string | undefined,
177181
agentName: args['agentName'] as string | undefined,
178-
controlPlaneUrl: args['controlPlaneUrl'] as string | undefined,
179-
adminKey: args['adminKey'] as string | undefined,
182+
controlPlaneUrl: cluster.controlPlaneUrl,
183+
adminKey: cluster.adminKey,
180184
sidecarUrl: args['sidecarUrl'] as string | undefined,
181185
})
182-
case 'agentspec_audit':
186+
}
187+
case 'agentspec_audit': {
188+
const cluster = resolveCluster({ controlPlaneUrl: args['controlPlaneUrl'] as string | undefined, adminKey: args['adminKey'] as string | undefined })
183189
return audit({
184190
file: args['file'] as string,
185191
pack: args['pack'] as string | undefined,
186192
agentName: args['agentName'] as string | undefined,
187-
controlPlaneUrl: args['controlPlaneUrl'] as string | undefined,
188-
adminKey: args['adminKey'] as string | undefined,
193+
controlPlaneUrl: cluster.controlPlaneUrl,
194+
adminKey: cluster.adminKey,
189195
sidecarUrl: args['sidecarUrl'] as string | undefined,
190196
})
197+
}
191198
case 'agentspec_scan':
192199
return scan(args['dir'] as string)
193200
case 'agentspec_generate':
@@ -196,24 +203,34 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
196203
args['framework'] as string,
197204
args['out'] as string | undefined,
198205
)
199-
case 'agentspec_proof':
206+
case 'agentspec_proof': {
207+
const cluster = resolveCluster({ controlPlaneUrl: args['controlPlaneUrl'] as string | undefined, adminKey: args['adminKey'] as string | undefined })
200208
return proof({
201209
agentName: args['agentName'] as string | undefined,
202-
controlPlaneUrl: args['controlPlaneUrl'] as string | undefined,
203-
adminKey: args['adminKey'] as string | undefined,
210+
controlPlaneUrl: cluster.controlPlaneUrl,
211+
adminKey: cluster.adminKey,
204212
sidecarUrl: args['sidecarUrl'] as string | undefined,
205213
})
206-
case 'agentspec_gap':
214+
}
215+
case 'agentspec_gap': {
216+
const cluster = resolveCluster({ controlPlaneUrl: args['controlPlaneUrl'] as string | undefined, adminKey: args['adminKey'] as string | undefined })
207217
return gap({
208218
agentName: args['agentName'] as string | undefined,
209-
controlPlaneUrl: args['controlPlaneUrl'] as string | undefined,
210-
adminKey: args['adminKey'] as string | undefined,
219+
controlPlaneUrl: cluster.controlPlaneUrl,
220+
adminKey: cluster.adminKey,
211221
sidecarUrl: args['sidecarUrl'] as string | undefined,
212222
})
223+
}
213224
case 'agentspec_diff':
214225
return diff(args['from'] as string, args['to'] as string)
215-
case 'agentspec_list_agents':
216-
return listAgents(args['dir'] as string | undefined)
226+
case 'agentspec_list_agents': {
227+
const cluster = resolveCluster({ controlPlaneUrl: args['controlPlaneUrl'] as string | undefined, adminKey: args['adminKey'] as string | undefined })
228+
return listAgents({
229+
dir: args['dir'] as string | undefined,
230+
controlPlaneUrl: cluster.controlPlaneUrl,
231+
adminKey: cluster.adminKey,
232+
})
233+
}
217234
default:
218235
throw new Error(`Unknown tool: ${name}`)
219236
}

0 commit comments

Comments
 (0)