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
17 changes: 15 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"test:watch": "bun test --watch",
"test:coverage": "bun test --coverage",
"test:e2e": "bun test tests/e2e",
"smoke:mcp-oauth": "bun run scripts/smoke-mcp-oauth.ts",
"dev:mcp-server": "bun run scripts/dev-mcp-server.ts",
"typecheck": "tsc --noEmit",
"lint": "biome lint src",
"lint:fix": "biome lint --write src",
Expand Down Expand Up @@ -55,7 +57,7 @@
"dependencies": {
"@clack/prompts": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"chalk": "^5.6.2",
"chalk": "^4.1.2",
"cmd-ts": "^0.14.3",
"execa": "^8.0.1",
"fast-glob": "^3.3.3",
Expand All @@ -75,6 +77,9 @@
"shx": "^0.4.0",
"typescript": "^5.3.3"
},
"overrides": {
"chalk": "^4.1.2"
},
"engines": {
"node": ">=18.0.0",
"bun": ">=1.0.0"
Expand Down
36 changes: 36 additions & 0 deletions scripts/dev-mcp-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bun
import { startDummyMcpOAuthServer } from '../tests/helpers/dummy-mcp-oauth-server.ts';

async function main() {
const server = await startDummyMcpOAuthServer();

console.log('Local dummy MCP + OAuth server running:');
console.log(` MCP endpoint: ${server.mcpUrl}`);
console.log(` OAuth issuer: ${server.idpIssuer}`);
console.log('');
console.log('Point allagents at it, e.g.:');
console.log(` allagents mcp add local-dev ${server.mcpUrl} --proxy`);
console.log(` bun run scripts/smoke-mcp-oauth.ts ${server.mcpUrl}`);
console.log('');
console.log(
'Authorization requests auto-approve immediately (no login screen) -- this is a',
);
console.log('CI-safe test double, not a real identity provider.');
console.log('');
console.log('Press Ctrl+C to stop.');

const shutdown = async () => {
await server.stop();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}

main().catch((error) => {
console.error(
'Failed to start dev MCP server:',
error instanceof Error ? error.message : error,
);
process.exit(1);
});
225 changes: 225 additions & 0 deletions scripts/smoke-mcp-oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
#!/usr/bin/env bun
import { connectToMcpProxy } from '../tests/helpers/mcp-proxy-client.ts';
import { startDummyMcpOAuthServer } from '../tests/helpers/dummy-mcp-oauth-server.ts';

interface ParsedArgs {
serverUrl?: string;
question: string;
tool?: string;
}

function parseArgs(argv: string[]): ParsedArgs {
const positionals: string[] = [];
let tool: string | undefined;

for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--tool') {
tool = argv[++i];
} else {
positionals.push(arg ?? '');
}
}

return {
serverUrl: positionals[0],
question: positionals[1] ?? 'how to rename a company branch',
tool,
};
}

const QUESTION_KEYS = ['question', 'query', 'q', 'prompt', 'text'];

interface JsonSchemaProperty {
type?: string;
minLength?: number;
items?: { enum?: unknown[] };
}

function defaultValueForProperty(
propSchema: JsonSchemaProperty | undefined,
question: string,
): unknown {
switch (propSchema?.type) {
case 'array': {
const enumValues = propSchema.items?.enum;
return Array.isArray(enumValues) && enumValues.length > 0
? [enumValues[0]]
: [];
}
case 'boolean':
return false;
case 'number':
case 'integer':
return 1;
case 'object':
return {};
default:
return (propSchema?.minLength ?? 0) > 0
? `Automated smoke test — answering: ${question}`
: '';
}
}

/**
* Fills every required property, not just a guessed "question" field — real tools
* (e.g. this server's search-knowledge-digested) require auxiliary fields like
* "explanation" or "sources" alongside the query itself, some with minLength/minItems
* constraints that plain empty defaults would fail.
*/
function buildToolArguments(
inputSchema: unknown,
question: string,
): Record<string, unknown> {
const schema =
inputSchema && typeof inputSchema === 'object' ? inputSchema : undefined;
const properties =
schema && 'properties' in schema && schema.properties && typeof schema.properties === 'object'
? (schema.properties as Record<string, JsonSchemaProperty>)
: undefined;
const required =
schema && 'required' in schema && Array.isArray((schema as { required: unknown }).required)
? ((schema as { required: string[] }).required as string[])
: [];

if (!properties) return { question };

const args: Record<string, unknown> = {};
const questionKey =
QUESTION_KEYS.find((key) => key in properties) ?? required[0];
if (questionKey) args[questionKey] = question;

for (const key of required) {
if (key in args) continue;
args[key] = defaultValueForProperty(properties[key], question);
}

return args;
}

function pickTool(
tools: Array<{ name: string; description?: string; inputSchema?: unknown }>,
requested: string | undefined,
): (typeof tools)[number] {
if (requested) {
const match = tools.find((t) => t.name === requested);
if (!match) {
throw new Error(
`Tool '${requested}' not found. Available: ${tools.map((t) => t.name).join(', ')}`,
);
}
return match;
}

if (tools.length === 1) {
return tools[0] as (typeof tools)[number];
}

const byPreference = [/digest/i, /ask/i, /search|query|question|knowledge/i];
for (const pattern of byPreference) {
const match = tools.find((t) => pattern.test(t.name));
if (match) return match;
}

throw new Error(
`Multiple tools available and none matched a search/ask heuristic. Pass --tool <name>. Available: ${tools
.map((t) => t.name)
.join(', ')}`,
);
}

async function main() {
const { serverUrl: explicitUrl, question, tool } = parseArgs(
process.argv.slice(2),
);

const selfContained = !explicitUrl;
const dummyServer = selfContained
? await startDummyMcpOAuthServer()
: undefined;
const serverUrl = explicitUrl ?? dummyServer?.mcpUrl;
if (!serverUrl) throw new Error('unreachable');

if (selfContained) {
console.log(
'No server URL provided — started a local dummy MCP+OAuth server (fully self-contained, no external network, no real login screen).',
);
}
console.log(`Connecting to ${serverUrl} via 'allagents mcp proxy'...`);
if (!selfContained) {
console.log(
'If this server requires OAuth, a browser window will open — complete the login there, then return here.',
);
}

try {
const connection = await connectToMcpProxy({
serverUrl,
env: selfContained ? { ALLAGENTS_MCP_OAUTH_NO_BROWSER: '1' } : undefined,
onAuthorizationUrl: (url) => {
if (selfContained) {
// Same trick the e2e tests use: the dummy IdP auto-approves any
// request, so a plain fetch (curl-equivalent) completes the login.
console.log('Completing OAuth automatically against the dummy IdP...');
fetch(url).catch((error) => {
console.error('Auto-authorize request failed:', error);
});
} else {
console.log(
`Authorization URL (in case the browser didn't open): ${url}`,
);
}
},
});

try {
console.log('Connected. Listing tools...');
const { tools } = await connection.client.listTools();
console.log(
`Available tools: ${tools.map((t) => t.name).join(', ') || '(none)'}`,
);

const selected = pickTool(tools, tool);
console.log(
`Selected tool '${selected.name}'. Input schema: ${JSON.stringify(selected.inputSchema)}`,
);
const args = buildToolArguments(selected.inputSchema, question);
console.log(
`Calling tool '${selected.name}' with arguments ${JSON.stringify(args)}...`,
);

const result = await connection.client.callTool({
name: selected.name,
arguments: args,
});

console.log('\n--- Response ---');
console.log(JSON.stringify(result, null, 2));
console.log('----------------\n');
if (selfContained) {
console.log(
'(This is a fixture response from the local dummy server, not real data.\n' +
'Each run starts a fresh dummy server on a new port, so this mode cannot\n' +
'demonstrate cached-token reuse — that\'s covered by tests/e2e/mcp-proxy-oauth.test.ts.\n' +
'Pass a real server URL as the first argument to test against something persistent.)',
);
} else {
console.log(
'Smoke test complete. Re-run this script again to confirm no second OAuth prompt appears (cached token reused).',
);
}
} finally {
await connection.close();
}
} finally {
await dummyServer?.stop();
}
}

main().catch((error) => {
console.error('Smoke test failed:', error instanceof Error ? error.message : error);
console.error(
"If this looks like a stale OAuth cache, inspect/clear the relevant directory under '~/.allagents/oauth-proxy/'.",
);
process.exit(1);
});
15 changes: 12 additions & 3 deletions src/core/mcp-http-stdio-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ import {
} from '@modelcontextprotocol/sdk/types.js';

const AUTH_TIMEOUT_MS = 5 * 60 * 1000;
export const AUTH_URL_LOG_PREFIX = 'If the browser does not open, visit: ';

function hashServerUrl(serverUrl: string): string {
export function hashServerUrl(serverUrl: string): string {
return createHash('sha256').update(serverUrl).digest('hex').slice(0, 16);
}

Expand Down Expand Up @@ -379,9 +380,17 @@ class FileOAuthClientProvider implements OAuthClientProvider {
server.listen(this.port, '127.0.0.1', () => {
console.error('Opening browser for authorization...');
console.error(
`If the browser does not open, visit: ${authorizationUrl.toString()}`,
`${AUTH_URL_LOG_PREFIX}${authorizationUrl.toString()}`,
);
void tryOpenBrowser(authorizationUrl.toString());
// Test-only escape hatch: e2e tests fetch the URL themselves against a local
// dummy IdP, and skipping the real OS browser-open avoids ever launching one.
if (process.env.ALLAGENTS_MCP_OAUTH_NO_BROWSER === '1') {
console.error(
'Skipping automatic browser open (ALLAGENTS_MCP_OAUTH_NO_BROWSER=1).',
);
} else {
void tryOpenBrowser(authorizationUrl.toString());
}
});
});
}
Expand Down
Loading