Skip to content

Commit 25d7289

Browse files
authored
feat: add MCP discovery router (#494)
1 parent 076f0c0 commit 25d7289

14 files changed

Lines changed: 788 additions & 5 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Minimal operating guide for AI coding agents in this repo.
5858
- TypeScript is strict enough to surface dead code early: `strict`, `isolatedModules`, `noUnusedLocals`, and `noUnusedParameters` are enabled.
5959
- The repo emits with `rslib`, not `tsc`. If declaration generation fails, inspect `tsconfig.lib.json` first.
6060
- `tsconfig.lib.json` needs an explicit `rootDir: "./src"` for declaration layout.
61+
- `server.json` MCP registry metadata must stay in sync with `package.json` `version` and `mcpName`; run `pnpm sync:mcp-metadata` after changing either field, and rely on `pnpm check:tooling`/CI to verify it.
6162
- Use the aggregate scripts in `package.json` when possible; they encode the expected validation bundles better than ad hoc command lists.
6263

6364
## Cheap Exploration
@@ -161,6 +162,7 @@ Command-only flags (like `find --first`) that don't flow to the platform layer o
161162
- Keep tests behavioral; do not assert shapes or cases TypeScript already proves.
162163
- Any TS change: `pnpm typecheck` or `pnpm check:quick`.
163164
- Tooling/config change (`package.json`, `tsconfig*.json`, `.oxlintrc.json`, `.oxfmtrc.json`): `pnpm check:tooling`.
165+
- MCP registry metadata changes (`server.json` or package `version`/`mcpName`): run `pnpm sync:mcp-metadata`, then `pnpm check:tooling`.
164166
- Daemon handler/shared module change: `pnpm check:unit`.
165167
- iOS runner/Swift change: `pnpm build:xcuitest`.
166168
- Cross-platform behavior change: run `pnpm test:integration`.

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,23 @@ The CLI help is the source of truth for agents and is shipped with the installed
4141

4242
If you install skills separately, keep the CLI on `agent-device >= 0.14.0`. Older CLIs do not include the workflow help topics that the router skills expect.
4343

44+
### MCP Router
45+
46+
`agent-device` also ships an official stdio MCP router for discovery-oriented clients. It exposes only `status`, `install`, and `help` tools plus workflow prompts/resources; device automation still runs through the CLI commands returned by version-matched help.
47+
48+
```json
49+
{
50+
"mcpServers": {
51+
"agent-device": {
52+
"command": "agent-device",
53+
"args": ["mcp"]
54+
}
55+
}
56+
}
57+
```
58+
59+
Registry metadata uses MCP name `io.github.callstackincubator/agent-device`, npm package `agent-device`, stdio transport, `mcpName` package verification, `server.json`, and `smithery.yaml`.
60+
4461
```bash
4562
npm install -g agent-device@latest
4663
agent-device --version
@@ -113,6 +130,7 @@ Agent integration:
113130
- [agent-device skill](skills/agent-device/SKILL.md)
114131
- [react-devtools skill](skills/react-devtools/SKILL.md)
115132
- [dogfood skill](skills/dogfood/SKILL.md)
133+
- MCP router: `agent-device mcp`
116134
- [agent-device skill on ClawHub](https://clawhub.ai/okwasniewski/agent-device)
117135

118136
## Contributing

package.json

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"name": "agent-device",
33
"version": "0.14.7",
44
"description": "Agent-driven CLI for mobile UI automation, network inspection, and performance diagnostics across iOS, Android, tvOS, and macOS.",
5+
"mcpName": "io.github.callstackincubator/agent-device",
56
"license": "MIT",
67
"author": "Callstack",
78
"homepage": "https://agent-device.dev/",
@@ -92,10 +93,12 @@
9293
"fallow:baseline": "(fallow dead-code --save-baseline fallow-baselines/dead-code.json --summary || true) && (fallow dupes --save-baseline fallow-baselines/dupes.json --summary || true) && (fallow health --save-baseline fallow-baselines/health.json --summary || true)",
9394
"check:fallow": "fallow audit",
9495
"check:quick": "pnpm lint && pnpm typecheck",
95-
"check:tooling": "pnpm lint && pnpm typecheck && pnpm build",
96+
"sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs",
97+
"check:mcp-metadata": "node scripts/sync-mcp-metadata.mjs --check",
98+
"check:tooling": "pnpm lint && pnpm typecheck && pnpm check:mcp-metadata && pnpm build",
9699
"check:unit": "pnpm test:unit && pnpm test:smoke",
97100
"check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit",
98-
"prepack": "pnpm build:all && pnpm package:android-snapshot-helper:npm",
101+
"prepack": "pnpm check:mcp-metadata && pnpm build:all && pnpm package:android-snapshot-helper:npm",
99102
"typecheck": "tsc -p tsconfig.json",
100103
"test-app:install": "pnpm install --dir examples/test-app --ignore-workspace",
101104
"test-app:start": "pnpm --dir examples/test-app start",
@@ -127,6 +130,8 @@
127130
"!android-snapshot-helper/dist/*.idsig",
128131
"src/platforms/linux/atspi-dump.py",
129132
"skills",
133+
"server.json",
134+
"smithery.yaml",
130135
"README.md",
131136
"LICENSE"
132137
],
@@ -152,7 +157,10 @@
152157
"diagnostics",
153158
"network",
154159
"profiling",
155-
"performance"
160+
"performance",
161+
"mcp",
162+
"model-context-protocol",
163+
"mcp-server"
156164
],
157165
"dependencies": {
158166
"fast-xml-parser": "^5.7.2",

scripts/sync-mcp-metadata.mjs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import process from 'node:process';
4+
5+
const root = process.cwd();
6+
const checkOnly = process.argv.includes('--check');
7+
const packagePath = path.join(root, 'package.json');
8+
const serverPath = path.join(root, 'server.json');
9+
10+
const pkg = readJson(packagePath);
11+
const server = readJson(serverPath);
12+
const expectedName = pkg.mcpName;
13+
const expectedVersion = pkg.version;
14+
15+
if (typeof expectedName !== 'string' || expectedName.length === 0) {
16+
fail('package.json must define mcpName.');
17+
}
18+
if (typeof expectedVersion !== 'string' || expectedVersion.length === 0) {
19+
fail('package.json must define version.');
20+
}
21+
22+
server.name = expectedName;
23+
server.version = expectedVersion;
24+
if (Array.isArray(server.packages)) {
25+
for (const packageEntry of server.packages) {
26+
if (packageEntry?.identifier === pkg.name) {
27+
packageEntry.version = expectedVersion;
28+
}
29+
}
30+
}
31+
32+
const next = `${JSON.stringify(server, null, 2)}\n`;
33+
const current = fs.readFileSync(serverPath, 'utf8');
34+
35+
if (checkOnly) {
36+
if (next !== current) {
37+
fail('server.json is out of sync. Run `pnpm sync:mcp-metadata`.');
38+
}
39+
process.exit(0);
40+
}
41+
42+
if (next !== current) {
43+
fs.writeFileSync(serverPath, next);
44+
}
45+
46+
function readJson(filePath) {
47+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
48+
}
49+
50+
function fail(message) {
51+
process.stderr.write(`${message}\n`);
52+
process.exit(1);
53+
}

server.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3+
"name": "io.github.callstackincubator/agent-device",
4+
"title": "agent-device",
5+
"description": "Official MCP discovery router for the agent-device CLI. It exposes status, install guidance, version-matched CLI help, prompts, and resources without exposing device automation over MCP.",
6+
"repository": {
7+
"url": "https://github.com/callstackincubator/agent-device",
8+
"source": "github"
9+
},
10+
"version": "0.14.7",
11+
"packages": [
12+
{
13+
"registryType": "npm",
14+
"identifier": "agent-device",
15+
"version": "0.14.7",
16+
"transport": {
17+
"type": "stdio"
18+
}
19+
}
20+
]
21+
}

smithery.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
runtime: "typescript"

src/bin.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1-
import { runCli } from './cli.ts';
1+
const argv = process.argv.slice(2);
22

3-
runCli(process.argv.slice(2));
3+
if (argv[0] === 'mcp' && !argv.includes('--help') && !argv.includes('-h')) {
4+
import('./mcp/server.ts')
5+
.then(({ runAgentDeviceMcpServer }) => runAgentDeviceMcpServer())
6+
.catch(handleStartupError);
7+
} else {
8+
import('./cli.ts').then(({ runCli }) => runCli(argv)).catch(handleStartupError);
9+
}
10+
11+
function handleStartupError(error: unknown): void {
12+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
13+
process.exit(1);
14+
}

src/mcp/__tests__/router.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { handleMcpMessage } from '../router.ts';
4+
import { handleMcpPayload } from '../server.ts';
5+
6+
test('MCP router exposes status install and help tools only', () => {
7+
const response = handleMcpMessage({
8+
jsonrpc: '2.0',
9+
id: 1,
10+
method: 'tools/list',
11+
});
12+
13+
assert.equal(response?.jsonrpc, '2.0');
14+
assert.ok(response && 'result' in response);
15+
const tools = (response.result as { tools: Array<{ name: string }> }).tools;
16+
assert.deepEqual(
17+
tools.map((tool) => tool.name),
18+
['status', 'install', 'help'],
19+
);
20+
});
21+
22+
test('MCP help tool returns versioned workflow guidance', () => {
23+
const response = handleMcpMessage({
24+
jsonrpc: '2.0',
25+
id: 2,
26+
method: 'tools/call',
27+
params: {
28+
name: 'help',
29+
arguments: { topic: 'workflow' },
30+
},
31+
});
32+
33+
assert.ok(response && 'result' in response);
34+
const result = response.result as { content: Array<{ text: string }>; isError: boolean };
35+
assert.equal(result.isError, false);
36+
assert.match(result.content[0]?.text ?? '', /agent-device help workflow/);
37+
assert.match(result.content[0]?.text ?? '', /snapshot -i/);
38+
});
39+
40+
test('MCP install tool can return npx client config', () => {
41+
const response = handleMcpMessage({
42+
jsonrpc: '2.0',
43+
id: 3,
44+
method: 'tools/call',
45+
params: {
46+
name: 'install',
47+
arguments: { global: false, client: 'Cline' },
48+
},
49+
});
50+
51+
assert.ok(response && 'result' in response);
52+
const result = response.result as { content: Array<{ text: string }> };
53+
const text = result.content[0]?.text ?? '';
54+
assert.match(text, /npx -y agent-device mcp/);
55+
assert.match(text, /"args": \["-y","agent-device","mcp"\]/);
56+
assert.match(text, /Client hint: Cline/);
57+
});
58+
59+
test('MCP exposes help resources and workflow prompts', () => {
60+
const resources = handleMcpMessage({
61+
jsonrpc: '2.0',
62+
id: 4,
63+
method: 'resources/list',
64+
});
65+
assert.ok(resources && 'result' in resources);
66+
const resourceUris = (resources.result as { resources: Array<{ uri: string }> }).resources.map(
67+
(resource) => resource.uri,
68+
);
69+
assert.ok(resourceUris.includes('agent-device://help/workflow'));
70+
71+
const prompt = handleMcpMessage({
72+
jsonrpc: '2.0',
73+
id: 5,
74+
method: 'prompts/get',
75+
params: {
76+
name: 'agent-device-dogfood',
77+
arguments: { target: 'SampleApp on iOS' },
78+
},
79+
});
80+
assert.ok(prompt && 'result' in prompt);
81+
const result = prompt.result as { messages: Array<{ content: { text: string } }> };
82+
assert.match(result.messages[0]?.content.text ?? '', /dogfood/);
83+
assert.match(result.messages[0]?.content.text ?? '', /SampleApp on iOS/);
84+
});
85+
86+
test('MCP initialize returns supported protocol version and unknown methods use JSON-RPC code', () => {
87+
const initialized = handleMcpMessage({
88+
jsonrpc: '2.0',
89+
id: 6,
90+
method: 'initialize',
91+
params: {
92+
protocolVersion: '2099-01-01',
93+
},
94+
});
95+
assert.ok(initialized && 'result' in initialized);
96+
assert.equal((initialized.result as { protocolVersion: string }).protocolVersion, '2025-11-25');
97+
98+
const unknown = handleMcpMessage({
99+
jsonrpc: '2.0',
100+
id: 7,
101+
method: 'unknown/method',
102+
});
103+
assert.ok(unknown && 'error' in unknown);
104+
assert.equal(unknown.error.code, -32601);
105+
});
106+
107+
test('MCP batch requests return one JSON-RPC array response', () => {
108+
const response = handleMcpPayload([
109+
{
110+
jsonrpc: '2.0',
111+
id: 8,
112+
method: 'ping',
113+
},
114+
{
115+
jsonrpc: '2.0',
116+
method: 'notifications/initialized',
117+
},
118+
{
119+
jsonrpc: '2.0',
120+
id: 9,
121+
method: 'tools/list',
122+
},
123+
]);
124+
125+
assert.ok(Array.isArray(response));
126+
assert.equal(response.length, 2);
127+
assert.equal(response[0]?.id, 8);
128+
assert.equal(response[1]?.id, 9);
129+
});

0 commit comments

Comments
 (0)