mirror of https://github.com/abpframework/abp.git
195 changed files with 2793 additions and 1984 deletions
@ -0,0 +1,164 @@ |
|||
```json |
|||
//[doc-seo] |
|||
{ |
|||
"Description": "Configure cross-application URLs in ABP with AppUrlOptions and IAppUrlProvider, including multi-tenant subdomain templates and redirect URL validation." |
|||
} |
|||
``` |
|||
|
|||
# Application URLs |
|||
|
|||
ABP provides the `AppUrlOptions` options class and the `IAppUrlProvider` service to centrally configure and resolve URLs that point to **other applications** in your solution (for example, an MVC/Razor Pages UI, an Auth Server, an HTTP API host, etc.). They are typically used when code in one application needs to build a link that targets another — like the Account module putting a **password reset link** into an email. |
|||
|
|||
* Defines `AppUrlOptions` to register the **root URL** and named relative URLs of each application. |
|||
* Provides `IAppUrlProvider` to **resolve** those URLs at runtime, with optional **tenant-aware** placeholder substitution. |
|||
* Supports **subdomain-style templates** (e.g. `https://{0}.example.com`) that produce per-tenant URLs without extra code. |
|||
* Maintains a `RedirectAllowedUrls` list used by `IAppUrlProvider.IsRedirectAllowedUrlAsync` to validate redirect targets. |
|||
|
|||
> `AppUrlOptions` is defined in the `Volo.Abp.UI.Navigation` package, which comes pre-installed with the [application startup template](../../solution-templates/layered-web-application). |
|||
|
|||
## Configuring Application URLs |
|||
|
|||
`AppUrlOptions` exposes a dictionary of **applications**, each with a `RootUrl` and a set of named `Urls`. |
|||
|
|||
**Example: Set the root URL and a named URL for the MVC application** |
|||
|
|||
```csharp |
|||
Configure<AppUrlOptions>(options => |
|||
{ |
|||
options.Applications["MVC"].RootUrl = "https://my-app.com"; |
|||
options.Applications["MVC"].Urls["MyPage"] = "my-page"; |
|||
}); |
|||
``` |
|||
|
|||
* `"MVC"` is the **application key**. Some modules (such as Account) register their URLs under a known key — `"MVC"` is the default for the **server-side UI**. You can use any key you want for your own applications. |
|||
* `RootUrl` is the **base URL** of that application. |
|||
* `Urls[urlName]` is a **relative path** appended to `RootUrl`. The final URL is built as `RootUrl.EnsureEndsWith('/') + Urls[urlName]`, so the relative path should **not** start with a `/`. When `RootUrl` is `null`, the value of `Urls[urlName]` is returned as-is. |
|||
|
|||
The Account module, for example, **pre-registers** its URLs in its application module: |
|||
|
|||
**Example: How the Account module registers the password reset URL** |
|||
|
|||
```csharp |
|||
Configure<AppUrlOptions>(options => |
|||
{ |
|||
options.Applications["MVC"].Urls[AccountUrlNames.PasswordReset] = "Account/ResetPassword"; |
|||
}); |
|||
``` |
|||
|
|||
> So configuring `Applications["MVC"].RootUrl` in your own module is usually enough to make password reset and similar Account email links point to the right host. |
|||
|
|||
### Defaults in the application startup template |
|||
|
|||
The ABP **application startup template** wires `Applications["MVC"].RootUrl` to the `App:SelfUrl` setting and seeds `RedirectAllowedUrls` from `App:RedirectAllowedUrls`: |
|||
|
|||
```csharp |
|||
Configure<AppUrlOptions>(options => |
|||
{ |
|||
options.Applications["MVC"].RootUrl = configuration["App:SelfUrl"]; |
|||
options.RedirectAllowedUrls.AddRange( |
|||
configuration["App:RedirectAllowedUrls"]?.Split(',') ?? Array.Empty<string>()); |
|||
}); |
|||
``` |
|||
|
|||
> This is why Account email links point to your **host URL** out of the box: they reuse `App:SelfUrl`. If that default isn't what you want — for example, in a subdomain-based **multi-tenant** setup — override `Applications["MVC"].RootUrl` with the template you need (see [Multi-Tenant Aware URLs](#multi-tenant-aware-urls)). |
|||
|
|||
## Using `IAppUrlProvider` |
|||
|
|||
[Inject](../fundamentals/dependency-injection.md) the `IAppUrlProvider` service into any class that needs to build a cross-application URL. |
|||
|
|||
**Example: Resolve a root URL and a named URL of the MVC application** |
|||
|
|||
```csharp |
|||
public class MyNotificationSender : ITransientDependency |
|||
{ |
|||
private readonly IAppUrlProvider _appUrlProvider; |
|||
|
|||
public MyNotificationSender(IAppUrlProvider appUrlProvider) |
|||
{ |
|||
_appUrlProvider = appUrlProvider; |
|||
} |
|||
|
|||
public async Task SendAsync() |
|||
{ |
|||
var rootUrl = await _appUrlProvider.GetUrlAsync("MVC"); |
|||
var pageUrl = await _appUrlProvider.GetUrlAsync("MVC", "MyPage"); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
* `GetUrlAsync(appName)` returns the configured `RootUrl` for the given application. |
|||
* `GetUrlAsync(appName, urlName)` returns the **combined URL** described above. |
|||
* `GetUrlAsync(...)` throws an `AbpException` when the resolved URL is `null` or empty (e.g. both `RootUrl` and `Urls[urlName]` are unset). Use `GetUrlOrNullAsync(...)` if you'd rather get `null` and decide what to do yourself. |
|||
* `NormalizeUrlAsync(url)` applies tenant placeholder substitution to a URL string that you already have. Useful when the URL doesn't come from `AppUrlOptions`. |
|||
|
|||
## Multi-Tenant Aware URLs |
|||
|
|||
If your solution uses **subdomain-based** multi-tenancy (see the [Domain/Subdomain Tenant Resolver](../architecture/multi-tenancy/index.md#domainsubdomain-tenant-resolver)), you'll usually want the **outbound URLs** you generate (email links, redirects) to also be tenant-aware — otherwise the link in a password reset email won't point to the tenant's subdomain. |
|||
|
|||
`AppUrlOptions` supports the following **placeholders** in any URL value. They are substituted by `IAppUrlProvider` based on the **current tenant**: |
|||
|
|||
| Placeholder | Replaced with | |
|||
| --- | --- | |
|||
| `{0}` | Current tenant **name** | |
|||
| `{%{{{ {{tenantName}} }}}%}` | Current tenant **name** | |
|||
| `{%{{{ {{tenantId}} }}}%}` | Current tenant **id** | |
|||
|
|||
The `{0}` placeholder uses the **same convention** as `AddDomainTenantResolver("{0}.example.com")`, so a typical subdomain-tenant setup looks like this: |
|||
|
|||
**Example: Tenant-aware Account email links via a subdomain template** |
|||
|
|||
```csharp |
|||
Configure<AbpTenantResolveOptions>(options => |
|||
{ |
|||
options.AddDomainTenantResolver("{0}.example.com"); |
|||
}); |
|||
|
|||
Configure<AppUrlOptions>(options => |
|||
{ |
|||
options.Applications["MVC"].RootUrl = "https://{0}.example.com"; |
|||
}); |
|||
``` |
|||
|
|||
With this configuration, password reset emails sent to a tenant whose name is `acme` will contain a link starting with `https://acme.example.com/`, matching the tenant's subdomain. |
|||
|
|||
### Host (no tenant) Fallback |
|||
|
|||
When there is **no current tenant** (host-side request), the placeholder **and the dot following it** are removed together: |
|||
|
|||
| Template | Tenant `acme` | Host (no tenant) | |
|||
| --- | --- | --- | |
|||
| `https://{0}.example.com` | `https://acme.example.com` | `https://example.com` | |
|||
| `https://{%{{{ {{tenantId}} }}}%}.example.com` | `https://3a21....example.com` | `https://example.com` | |
|||
|
|||
A single subdomain-style template like the ones above therefore works for **both** tenant and host scenarios without extra configuration. |
|||
|
|||
> If your subdomain is based on the tenant **id** rather than the name, use `https://{%{{{ {{tenantId}} }}}%}.example.com`. The resolver's `{0}` placeholder accepts both name and id when finding a tenant, but `AppUrlOptions` substitutes `{0}` with the tenant **name**; if those two don't match, switch to the explicit `{%{{{ {{tenantId}} }}}%}` form on the `AppUrlOptions` side. |
|||
|
|||
## Redirect Allowed URLs |
|||
|
|||
`AppUrlOptions.RedirectAllowedUrls` is a list of URL entries used by `IAppUrlProvider.IsRedirectAllowedUrlAsync(url)` to decide whether a redirect target is allowed. A URL is allowed when it satisfies **either** of: |
|||
|
|||
* **Prefix match**: the URL string **starts with** a configured entry (case-insensitive). |
|||
* **Subdomain match**: the URL and the entry have the **same scheme** and **port**, and the URL's host **ends with** `.{entry-host}`. |
|||
|
|||
**Example: Register allowed redirect URLs (including a wildcard)** |
|||
|
|||
```csharp |
|||
Configure<AppUrlOptions>(options => |
|||
{ |
|||
options.RedirectAllowedUrls.Add("https://my-app.com"); |
|||
options.RedirectAllowedUrls.Add("https://admin.my-app.com"); |
|||
|
|||
options.RedirectAllowedUrls.Add("https://*.my-app.com"); |
|||
}); |
|||
``` |
|||
|
|||
* A **plain entry** like `https://my-app.com` allows any URL that starts with that prefix, plus any subdomain of `my-app.com`. |
|||
* A **wildcard entry** like `https://*.my-app.com` allows any subdomain of `my-app.com`; the `*.` is stripped before the subdomain check. |
|||
* Entries also go through **tenant placeholder substitution**, so `https://{0}.my-app.com` is resolved to the current tenant's URL first (e.g. `https://acme.my-app.com`) and then compared. Use the wildcard form when you need to allow *any* tenant subdomain regardless of the current tenant. |
|||
|
|||
## See Also |
|||
|
|||
* [Multi-Tenancy](../architecture/multi-tenancy/index.md) |
|||
* [Account Module](../../modules/account.md) |
|||
* [Emailing](emailing.md) |
|||
@ -1,133 +0,0 @@ |
|||
```json |
|||
//[doc-seo] |
|||
{ |
|||
"Description": "Learn how to connect AI tools like Cursor, Claude Desktop, and VS Code to ABP Studio using the Model Context Protocol (MCP)." |
|||
} |
|||
``` |
|||
|
|||
# ABP Studio: Model Context Protocol (MCP) |
|||
|
|||
````json |
|||
//[doc-nav] |
|||
{ |
|||
"Next": { |
|||
"Name": "Working with Kubernetes", |
|||
"Path": "studio/kubernetes" |
|||
} |
|||
} |
|||
```` |
|||
|
|||
ABP Studio includes built-in [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) support so AI tools can query runtime telemetry and control solution runner operations. |
|||
|
|||
## How It Works |
|||
|
|||
ABP Studio runs a local MCP server in the background. The `abp mcp-studio` CLI command acts as a stdio bridge that AI clients connect to. The bridge forwards requests to ABP Studio and returns responses. |
|||
|
|||
```text |
|||
MCP Client (Cursor / Claude Desktop / VS Code) |
|||
──stdio──▶ abp mcp-studio ──HTTP──▶ ABP Studio |
|||
``` |
|||
|
|||
> ABP Studio must be running while MCP is used. If ABP Studio is not running (or its MCP endpoint is unavailable), `abp mcp-studio` returns an error to the AI client. |
|||
|
|||
## Configuration |
|||
|
|||
### Cursor (`.cursor/mcp.json`) |
|||
|
|||
```json |
|||
{ |
|||
"mcpServers": { |
|||
"abp-studio": { |
|||
"command": "abp", |
|||
"args": ["mcp-studio"] |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### Claude Desktop (`claude_desktop_config.json`) |
|||
|
|||
```json |
|||
{ |
|||
"mcpServers": { |
|||
"abp-studio": { |
|||
"command": "abp", |
|||
"args": ["mcp-studio"] |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
Claude Desktop config file locations: |
|||
|
|||
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` |
|||
- Windows: `%APPDATA%\Claude\claude_desktop_config.json` |
|||
- Linux: `~/.config/Claude/claude_desktop_config.json` |
|||
|
|||
### VS Code (`.vscode/mcp.json`) |
|||
|
|||
```json |
|||
{ |
|||
"servers": { |
|||
"abp-studio": { |
|||
"command": "abp", |
|||
"args": ["mcp-studio"] |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### Quick Reference |
|||
|
|||
You can run `abp help mcp-studio` at any time to see the available options and example configuration snippets for each supported IDE directly in your terminal. |
|||
|
|||
### Generating Config Files from ABP Studio |
|||
|
|||
When creating a new solution, ABP Studio can generate MCP configuration files for Cursor and VS Code automatically. |
|||
|
|||
## Available Tools |
|||
|
|||
ABP Studio exposes the following tools to MCP clients. All tools operate on the currently open solution and selected run profile in ABP Studio. |
|||
|
|||
### Monitoring |
|||
|
|||
| Tool | Description | |
|||
|------|-------------| |
|||
| `list_applications` | Lists all running ABP applications connected to ABP Studio. | |
|||
| `get_exceptions` | Gets recent exceptions including stack traces and error messages. | |
|||
| `get_logs` | Gets log entries. Can be filtered by log level. | |
|||
| `get_requests` | Gets HTTP request information. Can be filtered by status code. | |
|||
| `get_events` | Gets distributed events for debugging inter-service communication. | |
|||
| `clear_monitor` | Clears collected monitor data. | |
|||
|
|||
### Application Control |
|||
|
|||
| Tool | Description | |
|||
|------|-------------| |
|||
| `list_runnable_applications` | Lists all applications in the current run profile with their state. | |
|||
| `start_application` | Starts a stopped application. | |
|||
| `stop_application` | Stops a running application. | |
|||
| `restart_application` | Restarts a running application. | |
|||
| `build_application` | Builds a .NET application using `dotnet build`. | |
|||
|
|||
### Container Control |
|||
|
|||
| Tool | Description | |
|||
|------|-------------| |
|||
| `list_containers` | Lists Docker containers in the current run profile with their state. | |
|||
| `start_containers` | Starts Docker containers (docker-compose up). | |
|||
| `stop_containers` | Stops Docker containers (docker-compose down). | |
|||
|
|||
### Solution Structure |
|||
|
|||
| Tool | Description | |
|||
|------|-------------| |
|||
| `get_solution_info` | Gets solution name, path, template, and run profile information. | |
|||
| `list_modules` | Lists all modules in the solution. | |
|||
| `list_packages` | Lists packages (projects) in the solution. Can be filtered by module. | |
|||
| `get_module_dependencies` | Gets module dependency/import information. | |
|||
|
|||
## Notes |
|||
|
|||
- Monitor data (exceptions, logs, requests, events) is kept in memory and is cleared when the solution is closed. |
|||
- The `abp mcp-studio` command connects to the local ABP Studio instance. This is separate from the `abp mcp` command, which connects to the ABP.IO cloud MCP service and requires an active license. |
|||
@ -0,0 +1,157 @@ |
|||
using System; |
|||
using System.IO; |
|||
using System.Net; |
|||
using System.Net.Http; |
|||
using System.Text; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Microsoft.Extensions.DependencyInjection.Extensions; |
|||
using Shouldly; |
|||
using Volo.Abp.AspNetCore.TestBase; |
|||
using Volo.Abp.Http.Client; |
|||
using Volo.Abp.Http.Client.Proxying; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Http.DynamicProxying; |
|||
|
|||
public class ClientProxyResponseDisposal_Tests : AbpHttpClientTestBase |
|||
{ |
|||
private readonly IRegularTestController _controller; |
|||
private readonly StubProxyHttpClientFactory _factory; |
|||
|
|||
public ClientProxyResponseDisposal_Tests() |
|||
{ |
|||
_controller = ServiceProvider.GetRequiredService<IRegularTestController>(); |
|||
_factory = (StubProxyHttpClientFactory)ServiceProvider.GetRequiredService<IProxyHttpClientFactory>(); |
|||
} |
|||
|
|||
protected override void ConfigureServices(IServiceCollection services) |
|||
{ |
|||
services.Replace(ServiceDescriptor.Singleton<IProxyHttpClientFactory, StubProxyHttpClientFactory>()); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Non_Abp_Error_Response_Should_Dispose_HttpContent() |
|||
{ |
|||
var content = new TrackingHttpContent("Bad Gateway from upstream"); |
|||
_factory.SetStubResponse(() => new HttpResponseMessage(HttpStatusCode.BadGateway) |
|||
{ |
|||
Content = content |
|||
}); |
|||
|
|||
var exception = await Assert.ThrowsAsync<AbpRemoteCallException>( |
|||
() => _controller.IncrementValueAsync(1)); |
|||
|
|||
exception.HttpStatusCode.ShouldBe((int)HttpStatusCode.BadGateway); |
|||
content.Disposed.ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Successful_Response_With_Json_Return_Should_Dispose_HttpContent() |
|||
{ |
|||
var content = new TrackingHttpContent("42"); |
|||
_factory.SetStubResponse(() => new HttpResponseMessage(HttpStatusCode.OK) |
|||
{ |
|||
Content = content |
|||
}); |
|||
|
|||
var result = await _controller.IncrementValueAsync(0); |
|||
|
|||
result.ShouldBe(42); |
|||
content.Disposed.ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Successful_Response_With_Void_Return_Should_Dispose_HttpContent() |
|||
{ |
|||
var content = new TrackingHttpContent(string.Empty); |
|||
_factory.SetStubResponse(() => new HttpResponseMessage(HttpStatusCode.NoContent) |
|||
{ |
|||
Content = content |
|||
}); |
|||
|
|||
await _controller.GetException1Async(); |
|||
|
|||
content.Disposed.ShouldBeTrue(); |
|||
} |
|||
|
|||
class StubProxyHttpClientFactory : IProxyHttpClientFactory |
|||
{ |
|||
private readonly ITestServerAccessor _testServerAccessor; |
|||
|
|||
private int _count; |
|||
private Func<HttpResponseMessage> _responseFactory; |
|||
|
|||
public StubProxyHttpClientFactory(ITestServerAccessor testServerAccessor) |
|||
{ |
|||
_testServerAccessor = testServerAccessor; |
|||
} |
|||
|
|||
public void SetStubResponse(Func<HttpResponseMessage> responseFactory) |
|||
{ |
|||
_responseFactory = responseFactory; |
|||
} |
|||
|
|||
public HttpClient Create(string name) => Create(); |
|||
|
|||
public HttpClient Create() |
|||
{ |
|||
if (_count++ == 0) |
|||
{ |
|||
return _testServerAccessor.Server.CreateClient(); |
|||
} |
|||
|
|||
return new HttpClient(new StubHttpMessageHandler(() => |
|||
_responseFactory?.Invoke() |
|||
?? throw new InvalidOperationException("Stub response is not configured."))) |
|||
{ |
|||
BaseAddress = new Uri("http://localhost/") |
|||
}; |
|||
} |
|||
} |
|||
|
|||
class StubHttpMessageHandler : HttpMessageHandler |
|||
{ |
|||
private readonly Func<HttpResponseMessage> _responseFactory; |
|||
|
|||
public StubHttpMessageHandler(Func<HttpResponseMessage> responseFactory) |
|||
{ |
|||
_responseFactory = responseFactory; |
|||
} |
|||
|
|||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) |
|||
{ |
|||
return Task.FromResult(_responseFactory()); |
|||
} |
|||
} |
|||
|
|||
class TrackingHttpContent : HttpContent |
|||
{ |
|||
private readonly byte[] _data; |
|||
|
|||
public bool Disposed { get; private set; } |
|||
|
|||
public TrackingHttpContent(string text) |
|||
{ |
|||
_data = Encoding.UTF8.GetBytes(text); |
|||
} |
|||
|
|||
protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) |
|||
{ |
|||
return stream.WriteAsync(_data, 0, _data.Length); |
|||
} |
|||
|
|||
protected override bool TryComputeLength(out long length) |
|||
{ |
|||
length = _data.Length; |
|||
return true; |
|||
} |
|||
|
|||
protected override void Dispose(bool disposing) |
|||
{ |
|||
Disposed = true; |
|||
base.Dispose(disposing); |
|||
} |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue