Skip to content

Commit 737644e

Browse files
authored
fix: merge globals into mcp tool schemas and openapi operations (#260)
* fix: merge globals into mcp tool schemas and openapi operations Global options were only parsed from argv and HTTP command routes. MCP tools (stdio and HTTP) omitted them from input schemas and ran commands and middleware with empty globals, and the generated OpenAPI document left them out of every operation. Merge the globals schema into each tool's input schema and OpenAPI operation, and split global keys back out of tool calls, validating them with the globals schema before middleware runs. A global wins over a command option of the same name, as on the CLI and over HTTP. * fix: keep mcp positional values that share a global's name * fix: avoid reparsing mcp globals after direct tool validation * fix: type mcp serve middleware globals from the globals schema * fix: document the mcp callTool globals option * fix: mark the mcp globals changeset as minor
1 parent 67a09c6 commit 737644e

8 files changed

Lines changed: 436 additions & 29 deletions

File tree

‎.changeset/mcp-globals.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'incur': minor
3+
---
4+
5+
Added `globals` support to MCP tools and the generated OpenAPI document. Global options are now merged into every MCP tool's input schema (stdio `--mcp` and HTTP `/mcp`) and into each OpenAPI operation's query parameters or request body. MCP tool calls split them back out, apply the globals schema's defaults and validation once, and pass them to commands and middleware as `globals`. `Mcp.serve` accepts a `globals` schema for CLIs that serve their command map directly, and types `c.globals` in its middlewares from it. A global wins over a command option of the same name, as on the CLI.

‎src/Cli.test.ts‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6644,7 +6644,7 @@ describe('fetch', () => {
66446644
}
66456645

66466646
async function mcpRequest(
6647-
cli: Cli.Cli<any, any, any>,
6647+
cli: Cli.Cli<any, any, any, any>,
66486648
body: unknown,
66496649
sessionId?: string,
66506650
extraHeaders: Record<string, string> = {},
@@ -7020,6 +7020,48 @@ describe('fetch', () => {
70207020
`)
70217021
})
70227022

7023+
test('POST /mcp merges globals into tool schemas and passes them to calls', async () => {
7024+
const cli = Cli.create('test', {
7025+
globals: z.object({ rpcUrl: z.string().default('fallback') }),
7026+
mcp: { tools: { discovery: 'direct' } },
7027+
vars: z.object({ rpcUrl: z.string().default('') }),
7028+
version: '1.0.0',
7029+
})
7030+
.use(async (c, next) => {
7031+
c.set('rpcUrl', c.globals.rpcUrl)
7032+
await next()
7033+
})
7034+
.command('ping', {
7035+
options: z.object({ limit: z.number().default(0) }),
7036+
run: (c) => ({ globals: c.globals, limit: c.options.limit, middleware: c.var.rpcUrl }),
7037+
})
7038+
7039+
const list = await mcpRequest(cli, {
7040+
jsonrpc: '2.0',
7041+
id: 1,
7042+
method: 'tools/list',
7043+
params: {},
7044+
})
7045+
const call = await mcpRequest(cli, {
7046+
jsonrpc: '2.0',
7047+
id: 2,
7048+
method: 'tools/call',
7049+
params: { name: 'ping', arguments: { limit: 3, rpcUrl: 'http://example.com' } },
7050+
})
7051+
7052+
const [tool] = (await list.json()).result.tools
7053+
expect(Object.keys(tool.inputSchema.properties)).toEqual(['limit', 'rpcUrl'])
7054+
expect(JSON.parse((await call.json()).result.content[0].text)).toMatchInlineSnapshot(`
7055+
{
7056+
"globals": {
7057+
"rpcUrl": "http://example.com",
7058+
},
7059+
"limit": 3,
7060+
"middleware": "http://example.com",
7061+
}
7062+
`)
7063+
})
7064+
70237065
test('POST /mcp exposes per-request headers to command context', async () => {
70247066
const cli = Cli.create('test', {
70257067
version: '1.0.0',
@@ -7710,6 +7752,20 @@ describe('--mcp', () => {
77107752
}
77117753
})
77127754

7755+
test('globals from create() are forwarded to Mcp.serve', async () => {
7756+
const spy = vi.spyOn(Mcp, 'serve').mockResolvedValue(undefined)
7757+
try {
7758+
const globals = z.object({ rpcUrl: z.string() })
7759+
const cli = Cli.create('test', { globals })
7760+
cli.command('ping', { run: () => ({ pong: true }) })
7761+
await cli.serve(['--mcp'])
7762+
expect(spy).toHaveBeenCalledOnce()
7763+
expect(spy.mock.calls[0]![3]?.globals).toBe(globals)
7764+
} finally {
7765+
spy.mockRestore()
7766+
}
7767+
})
7768+
77137769
test('instructions is omitted from Mcp.serve when not set in create()', async () => {
77147770
const spy = vi.spyOn(Mcp, 'serve').mockResolvedValue(undefined)
77157771
try {

‎src/Cli.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -905,6 +905,7 @@ async function serveImpl(
905905
await Mcp.serve(options.mcp?.name ?? name, options.version ?? '0.0.0', commands, {
906906
middlewares: options.middlewares,
907907
env: options.envSchema,
908+
globals: options.globals?.schema,
908909
vars: options.vars,
909910
version: options.version,
910911
...(options.mcp?.instructions ? { instructions: options.mcp.instructions } : undefined),
@@ -2000,6 +2001,7 @@ declare namespace fetchImpl {
20002001
mcpOptions?: {
20012002
middlewares?: MiddlewareHandler[] | undefined
20022003
env?: z.ZodObject<any> | undefined
2004+
globals?: z.ZodObject<any> | undefined
20032005
vars?: z.ZodObject<any> | undefined
20042006
},
20052007
) => Promise<Response>)
@@ -2028,6 +2030,7 @@ function createMcpHttpHandler(
20282030
| {
20292031
middlewares?: MiddlewareHandler[] | undefined
20302032
env?: z.ZodObject<any> | undefined
2033+
globals?: z.ZodObject<any> | undefined
20312034
vars?: z.ZodObject<any> | undefined
20322035
}
20332036
| undefined,
@@ -2047,6 +2050,7 @@ function createMcpHttpHandler(
20472050
Mcp.registerTools(server, commands, {
20482051
env: mcpOptions?.env,
20492052
fromJsonSchema,
2053+
globals: mcpOptions?.globals,
20502054
middlewares: mcpOptions?.middlewares,
20512055
name,
20522056
request: (extra) => extra?.http?.req,
@@ -2074,6 +2078,7 @@ function createMcpHttpHandler(
20742078
mcpOptions?: {
20752079
middlewares?: MiddlewareHandler[] | undefined
20762080
env?: z.ZodObject<any> | undefined
2081+
globals?: z.ZodObject<any> | undefined
20772082
vars?: z.ZodObject<any> | undefined
20782083
},
20792084
): Promise<Response> => {
@@ -2152,6 +2157,7 @@ function generatedOpenapi(
21522157
const openapiCli = { name, description: options.description } as Cli
21532158
toCommands.set(openapiCli, commands)
21542159
if (options.rootCommand) toRootDefinition.set(openapiCli as unknown as Root, options.rootCommand)
2160+
if (options.globals) toGlobals.set(openapiCli, options.globals)
21552161
return Openapi.fromCli(openapiCli, {
21562162
title: name,
21572163
...(options.version ? { version: options.version } : undefined),
@@ -2189,6 +2195,7 @@ async function fetchImpl(
21892195
return options.mcpHandler(req, commands, {
21902196
middlewares: options.middlewares,
21912197
env: options.envSchema,
2198+
globals: options.globals?.schema,
21922199
vars: options.vars,
21932200
})
21942201

@@ -3046,6 +3053,7 @@ async function runMcpDoctor(
30463053
output,
30473054
middlewares: options.middlewares,
30483055
env: options.envSchema,
3056+
globals: options.globals?.schema,
30493057
vars: options.vars,
30503058
version: options.version,
30513059
...(options.mcp?.instructions ? { instructions: options.mcp.instructions } : undefined),
@@ -3443,7 +3451,7 @@ export type GlobalsDescriptor = {
34433451
}
34443452

34453453
/** @internal Maps CLI instances to their globals schema and alias map. */
3446-
const toGlobals = new WeakMap<Cli, GlobalsDescriptor>()
3454+
export const toGlobals = new WeakMap<Cli, GlobalsDescriptor>()
34473455

34483456
/** @internal Maps root CLI instances to their command aliases. */
34493457
const toRootAliases = new WeakMap<Root, string[]>()

‎src/Mcp.test-d.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Mcp, z } from 'incur'
2+
import { expectTypeOf, test } from 'vitest'
3+
4+
test('globals type flows to Mcp.serve middleware context', () => {
5+
void Mcp.serve('test', '1.0.0', new Map(), {
6+
globals: z.object({
7+
network: z.enum(['mainnet', 'testnet']).default('mainnet'),
8+
rpcUrl: z.string().transform((value) => new URL(value)),
9+
}),
10+
middlewares: [
11+
(c, next) => {
12+
expectTypeOf(c.globals).toEqualTypeOf<{ network: 'mainnet' | 'testnet'; rpcUrl: URL }>()
13+
return next()
14+
},
15+
],
16+
})
17+
})
18+
19+
test('without globals, Mcp.serve middleware sees empty globals', () => {
20+
void Mcp.serve('test', '1.0.0', new Map(), {
21+
middlewares: [
22+
(c, next) => {
23+
expectTypeOf(c.globals).toEqualTypeOf<{}>()
24+
return next()
25+
},
26+
],
27+
})
28+
})

0 commit comments

Comments
 (0)